feat: M4 docker deployment - bridge + frontend containers
Some checks failed
CI / backend (push) Failing after 37s
CI / frontend (push) Failing after 21s

- packages/bridge/Dockerfile: multi-stage build (node:22-alpine)
- frontend/Dockerfile: multi-stage build + nginx:alpine
- frontend/nginx.conf: proxy /bridge to bridge service
- docker-compose.yml: bridge (3456) + frontend (8080)
- .dockerignore: exclude backend/node_modules/dist
- Fix vite proxy target to port 3456
- .env with bridge vars (AUTH_MODE=dev)
This commit is contained in:
kisskin 2026-07-10 14:58:20 +03:00
parent dbf290e49a
commit ade88808fb
38 changed files with 3508 additions and 2930 deletions

32
.dockerignore Normal file
View file

@ -0,0 +1,32 @@
.git
.env
.env.*
!.env.example
.venv
__pycache__
.pytest_cache
.ruff_cache
.mypy_cache
*.py[cod]
# Node modules (re-installed in Docker)
**/node_modules
# Build outputs (re-built in Docker)
frontend/dist
packages/*/dist
*.tsbuildinfo
# Dev/test artifacts
coverage
*.log
.DS_Store
.idea
.vscode
# Docker-in-Docker
docker-compose.yml
**/Dockerfile
# Backend (not needed for bridge/frontend containers)
backend/

View file

@ -1,9 +1,21 @@
# ─── Bridge Configuration ──────────────────────────────
BRIDGE_HOST=127.0.0.1
BRIDGE_PORT=8787
BRIDGE_TOKEN=dev-secret-token
AUTH_MODE=dev
BRIDGE_ALLOWED_BOARDS=*
COMMAND_TIMEOUT_SECONDS=30
LOG_TAIL_MAX_LINES=500
# ─── Hermes CLI ────────────────────────────────────────
HERMES_BIN=hermes HERMES_BIN=hermes
HERMES_HOME=/home/your-user/.hermes HERMES_HOME=/home/kisskin/.hermes
HERMES_KANBAN_BOARD=default
KANBAN_UI_AUTH_MODE=dev # ─── Telegram (optional, for production) ───────────────
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS=
KANBAN_UI_ALLOWED_ORIGINS=http://localhost:5173 # ─── Frontend (Vite) ──────────────────────────────────
KANBAN_UI_COMMAND_TIMEOUT_SECONDS=30 # In dev mode the vite proxy handles /bridge → localhost:8787
VITE_API_BASE_URL=http://localhost:8000 # Set VITE_BRIDGE_URL only if frontend talks to bridge directly
# VITE_BRIDGE_URL=http://localhost:8787/bridge
# VITE_AUTH_TOKEN=dev-secret-token

View file

@ -1,8 +1,17 @@
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.11-slim AS backend FROM python:3.11-slim AS backend
WORKDIR /app WORKDIR /app
COPY backend/pyproject.toml /app/backend/pyproject.toml COPY backend/pyproject.toml /app/backend/pyproject.toml
COPY backend/app /app/backend/app COPY backend/app /app/backend/app
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
RUN pip install --no-cache-dir -e /app/backend RUN pip install --no-cache-dir -e /app/backend
ENV KANBAN_UI_AUTH_MODE=telegram ENV KANBAN_UI_AUTH_MODE=telegram \
KANBAN_UI_FRONTEND_DIST_DIR=/app/frontend/dist
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -83,6 +83,7 @@ Important settings:
- `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation. - `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation.
- `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist. - `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist.
- `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist. - `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist.
- `KANBAN_UI_FRONTEND_DIST_DIR`: optional built frontend directory for single-container serving.
- `VITE_API_BASE_URL`: frontend API base URL for production builds. - `VITE_API_BASE_URL`: frontend API base URL for production builds.
## Telegram Mini App setup ## Telegram Mini App setup
@ -126,6 +127,9 @@ npm ci
npm run build npm run build
``` ```
The included Dockerfile builds the frontend and serves it from the FastAPI container on port `8000`.
For same-origin Docker deployment, leave `VITE_API_BASE_URL` empty at build time and expose only the backend container.
## Security model ## Security model
- Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands. - Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands.

View file

@ -1,8 +1,9 @@
import json
from functools import lru_cache from functools import lru_cache
from typing import Literal from typing import Annotated, Literal
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
@ -13,17 +14,27 @@ class Settings(BaseSettings):
default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD") default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD")
auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE") auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE")
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN") telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_allowed_user_ids: list[int] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS") telegram_allowed_user_ids: Annotated[list[int], NoDecode] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS") allowed_origins: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS") command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS")
frontend_dist_dir: str | None = Field(default=None, alias="KANBAN_UI_FRONTEND_DIST_DIR")
@field_validator("telegram_allowed_user_ids", mode="before") @field_validator("telegram_allowed_user_ids", mode="before")
@classmethod @classmethod
def parse_user_ids(cls, value): def parse_user_ids(cls, value):
if value is None or value == "": if value is None or value == "":
return [] return []
if isinstance(value, str):
value = value.strip()
if value.startswith("["):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
if isinstance(value, str): if isinstance(value, str):
return [int(part.strip()) for part in value.split(",") if part.strip()] return [int(part.strip()) for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple)):
return [int(v) for v in value]
return value return value
@field_validator("allowed_origins", mode="before") @field_validator("allowed_origins", mode="before")
@ -31,8 +42,17 @@ class Settings(BaseSettings):
def parse_origins(cls, value): def parse_origins(cls, value):
if value is None or value == "": if value is None or value == "":
return [] return []
if isinstance(value, str):
value = value.strip()
if value.startswith("["):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
if isinstance(value, str): if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()] return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple)):
return [str(v) for v in value]
return value return value

View file

@ -128,6 +128,14 @@ def create_app(settings: Settings | None = None, adapter: KanbanAdapter | None =
def dispatch(board: str | None = Query(default=None), user=Depends(require_user)): def dispatch(board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.dispatch, selected_board(board)) return safe_call(adapter.dispatch, selected_board(board))
if settings.frontend_dist_dir:
from pathlib import Path
from fastapi.staticfiles import StaticFiles
frontend_dir = Path(settings.frontend_dist_dir)
if frontend_dir.is_dir():
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
return app return app

View file

@ -118,6 +118,35 @@ def test_allowed_user_filter_rejects_unauthorized(fake_adapter):
assert response.status_code == 403 assert response.status_code == 403
def test_env_list_settings_accept_json_strings():
settings = Settings(
telegram_allowed_user_ids='[123, "456"]',
allowed_origins='["https://miniapp.example", "http://localhost:5173"]',
)
assert settings.telegram_allowed_user_ids == [123, 456]
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
def test_env_list_settings_accept_comma_separated_strings():
settings = Settings(
telegram_allowed_user_ids="123,456",
allowed_origins="https://miniapp.example,http://localhost:5173",
)
assert settings.telegram_allowed_user_ids == [123, 456]
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
def test_static_frontend_can_be_served_from_backend(tmp_path, fake_adapter):
(tmp_path / "index.html").write_text("<h1>Kanban Mini App</h1>", encoding="utf-8")
settings = Settings(auth_mode="none", frontend_dist_dir=str(tmp_path))
app = create_app(settings, fake_adapter)
response = TestClient(app).get("/")
assert response.status_code == 200
assert "Kanban Mini App" in response.text
def test_task_list_returns_normalized_tasks_grouped_by_status(client): def test_task_list_returns_normalized_tasks_grouped_by_status(client):
data = client.get("/api/tasks").json() data = client.get("/api/tasks").json()
assert data["groups"]["ready"][0]["id"] == "t_1" assert data["groups"]["ready"][0]["id"] == "t_1"

View file

@ -1,22 +1,48 @@
# ─────────────────────────────────────────────────────────────
# docker-compose.yml — Hermes Kanban MiniApp deployment
# Services: bridge (Express API) + frontend (Nginx SPA)
# ─────────────────────────────────────────────────────────────
services: services:
api: # ── Bridge API ───────────────────────────────────────────
build: . bridge:
env_file: .env build:
context: .
dockerfile: packages/bridge/Dockerfile
ports: ports:
- "8000:8000" - "3456:3456"
env_file: .env
environment:
# Override bind to 0.0.0.0 so container is reachable
BRIDGE_HOST: "0.0.0.0"
BRIDGE_PORT: "3456"
volumes: volumes:
- ${HERMES_HOME:-/home/your-user/.hermes}:${HERMES_HOME:-/home/your-user/.hermes}:ro # Mount hermes profile directory (read-only)
- ${HERMES_HOME:-/home/kisskin/.hermes}:/root/.hermes:ro
networks:
- kanban
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3456/bridge/v1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ── Frontend (Nginx SPA) ─────────────────────────────────
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
ports:
- "8080:80"
depends_on:
bridge:
condition: service_healthy
networks:
- kanban
restart: unless-stopped restart: unless-stopped
frontend: networks:
image: node:22-alpine kanban:
working_dir: /app/frontend driver: bridge
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
environment:
VITE_API_BASE_URL: http://localhost:8000
volumes:
- .:/app
ports:
- "5173:5173"
depends_on:
- api

665
docs/architecture-adr.md Normal file
View file

@ -0,0 +1,665 @@
# ADR-001: Community architecture for Hermes Kanban Telegram Mini App
Status: Proposed for implementation
Date: 2026-07-10
Decision owner: project maintainer
Scope: open-source community release, before rewriting the current local FastAPI prototype
## 1. Context
The current repository contains an uncommitted prototype with a Vite/React frontend and a Python/FastAPI backend that shells out to `hermes kanban`. The target changed: the public release should be best-practice, secure, easy to install, documented in English and Russian, pleasant as a Telegram Mini App and normal browser/mobile app, and work well on Vercel.
Hard constraint: a static Vercel frontend cannot safely execute a user's local `hermes` CLI or read local `~/.hermes/kanban.db`. Any architecture that claims otherwise is untrustworthy. A remote browser UI must talk to a trusted server-side component that is already authorized to reach the user's Hermes instance.
## 2. Verified facts used for this ADR
### Hermes Agent
From the official Hermes docs and local checked source at `/home/kisskin/.hermes/hermes-agent`:
- Hermes API Server exposes Hermes as an OpenAI-compatible HTTP endpoint for external frontends (`POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/runs`, sessions, jobs, skills/toolsets discovery, health). Source: `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/api-server.md`.
- API Server is enabled with env vars including `API_SERVER_ENABLED=true`, `API_SERVER_KEY`, `API_SERVER_HOST`, `API_SERVER_PORT`, and optional `API_SERVER_CORS_ORIGINS`. Default bind host is `127.0.0.1`; bearer auth is required. Source: same file, Configuration and Authentication sections.
- Hermes API Server is powerful: it gives access to the agent's toolset including terminal commands, so the key must never be exposed to a browser. Source: same file, Security warning.
- Current API Server capabilities do not advertise first-class Kanban CRUD endpoints. The capabilities list includes chat/responses/runs, sessions, jobs, skills, and toolsets; searching `gateway/platforms/api_server.py` found no `/api/kanban` route.
- Hermes Kanban is a durable SQLite-backed board. Tasks, comments, links, workspaces, logs, dispatcher behavior, boards, and board isolation are documented in `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/kanban.md`.
- Hermes Kanban has two official surfaces today: agents use `kanban_*` tools, while humans/scripts use `hermes kanban ...` CLI, slash command, or the Hermes dashboard. Source: Kanban docs section "Two surfaces".
### Telegram Mini Apps
From official Telegram Mini Apps docs and Telegram core docs:
- Mini App init data is intended to be used as an authentication/authorization factor.
- Server validation requires excluding `hash`, sorting key-value pairs, joining them with `\n`, deriving a secret with HMAC-SHA256 over the bot token using `WebAppData`, then HMAC-SHA256 over the data-check string and comparing hex digest to `hash`.
- The bot token must remain server-side. A frontend may forward raw init data to its backend, but must not validate with the bot token in the browser.
- The official React ecosystem template for Telegram Mini Apps uses React, TypeScript, Vite, `@tma.js/sdk-react` / Telegram Apps SDK packages, React Router HashRouter, and Telegram UI components.
### Vercel / browser deployment
- Vercel is a good fit for Vite static frontend hosting and for lightweight Node.js serverless API routes.
- Vercel serverless functions can call HTTPS APIs, but they cannot be trusted to execute a user's private local `hermes` CLI or access a local SQLite board unless the user separately exposes a backend/bridge over HTTPS.
- Browser-side direct calls to Hermes API Server are unsafe for a community default because they would require exposing `API_SERVER_KEY` to JavaScript. If direct browser mode is documented, it must be explicitly marked local/dev only.
### Comparable open-source Telegram Mini Apps
GitHub search verified active public projects/templates:
- `Telegram-Mini-Apps/reactjs-template`: React, TypeScript, Vite, Telegram Mini Apps SDK, Telegram UI; suitable baseline for project structure.
- `telegram-mini-apps-dev/TelegramUI`: React component library inspired by Telegram interface; suitable UI dependency.
- Other public Telegram Mini App examples commonly combine React/TypeScript with Next.js or Vite and place secrets in a backend/server component, not in static browser code.
## 3. Decision
Use this stack decisively:
1. Frontend: React + TypeScript + Vite, hosted as static assets on Vercel or any static host.
2. Telegram SDK/UI: `@telegram-apps/sdk-react` (or current successor package from Telegram Mini Apps docs) + `@telegram-apps/telegram-ui` for native-feeling Telegram UX.
3. API layer for Vercel/community default: TypeScript serverless BFF (Backend-for-Frontend) API routes deployed on Vercel.
4. Local integration: optional self-hosted Hermes Kanban Bridge, implemented in TypeScript/Node.js, running on the same trusted machine as Hermes.
5. Data source: the bridge uses stable `hermes kanban ... --json` CLI commands first, with fixed argv and `shell: false`. It may later switch to official Hermes Kanban HTTP endpoints if Hermes adds them.
6. Authentication: Vercel BFF validates Telegram `initData` server-side and issues a short-lived app session. The BFF then calls the self-hosted bridge using a separate bridge token. The bridge never trusts the browser directly.
7. Documentation: docs must include English and Russian install paths for Vercel + bridge, Docker Compose, and local dev.
Short version: `Vercel static React Mini App + Vercel TS BFF + optional self-hosted TS bridge near Hermes`.
## 4. Why not keep the prototype stack as-is?
The Python/FastAPI prototype proved the shape, but it is not the best public target because:
- The user prefers TypeScript/JS and Vercel.
- A Vercel-centered release is simpler if frontend and BFF share TypeScript types and validation schemas.
- A Python backend on Vercel is possible but less ergonomic for this use case than a small Node BFF plus a separate self-hosted bridge.
- The current prototype mixes concerns: Telegram auth, browser CORS, Hermes CLI subprocess, and static serving in one backend. For community release, the trust boundary must be explicit.
Python/FastAPI is not rejected forever; it becomes a migration source and optional legacy adapter, not the recommended architecture.
## 5. Target architecture
```text
Telegram client / mobile browser / desktop browser
|
| HTTPS, static assets, no secrets
v
Vercel-hosted React + TypeScript + Vite app
|
| Authorization: tma <Telegram initData>
| or app session cookie/JWT after validation
v
Vercel TypeScript BFF API routes
| - validates Telegram initData with TELEGRAM_BOT_TOKEN
| - enforces allowed Telegram user IDs / roles
| - rate limits and normalizes errors
| - holds no Hermes CLI access
|
| HTTPS + Bridge token, server-to-server only
v
Optional self-hosted Hermes Kanban Bridge (Node.js)
| - runs on trusted host near Hermes
| - validates bridge token/mTLS or reverse-proxy auth
| - maps narrow REST endpoints to fixed Hermes Kanban operations
| - uses child_process.spawn/execFile with shell=false
v
Hermes install / profile
|
v
Hermes Kanban board SQLite + dispatcher + logs/workspaces
```
### Local-only shortcut
For local development only:
```text
Vite dev server -> local bridge on 127.0.0.1 -> hermes kanban CLI
```
This mode may use `AUTH_MODE=dev` and a fake Telegram user fixture, but it must be impossible to enable in production without an explicit env var.
## 6. Trust boundaries
### Public browser / Mini App
Trusted for display only. Not trusted for identity, permissions, board slug, task IDs, or action payloads. It must not receive:
- Telegram bot token.
- Hermes API Server key.
- Bridge token.
- Local filesystem paths beyond what the bridge explicitly exposes as display-safe metadata.
- Raw command output containing secrets.
### Vercel BFF
Trusted to authenticate Telegram users and authorize UI actions. It has:
- `TELEGRAM_BOT_TOKEN`.
- `SESSION_SECRET` or JWT signing secret.
- `BRIDGE_BASE_URL` and `BRIDGE_TOKEN` if a bridge is configured.
It does not have direct local CLI access.
### Self-hosted bridge
Trusted local integration process. It has:
- Access to `hermes` binary and `HERMES_HOME`.
- Optional `API_SERVER_KEY` only if a future bridge mode calls Hermes API Server for non-Kanban agent actions.
- A bridge token accepted only from the BFF/reverse proxy.
### Hermes API Server
Powerful agent API. Do not expose directly to the Mini App browser for community default. If users expose it, they must keep bearer auth server-side and set narrow CORS only when absolutely needed.
## 7. Authentication model
### Telegram initData validation
Recommended request from frontend to BFF:
```http
Authorization: tma <raw initData>
```
Server validation steps:
1. Parse query-string-like init data.
2. Remove and store `hash`.
3. Sort remaining key-value pairs alphabetically.
4. Join as `key=value` lines separated by `\n`.
5. Derive secret key: HMAC-SHA256 with key `WebAppData` and message `TELEGRAM_BOT_TOKEN`.
6. Compute HMAC-SHA256 of the data-check string using the derived key.
7. Compare hex digest to received `hash` using constant-time comparison.
8. Reject missing/invalid `auth_date`, expired data, missing user, or malformed JSON.
9. Apply allowlist/RBAC: `TELEGRAM_ALLOWED_USER_IDS`, optional `TELEGRAM_ADMIN_USER_IDS`.
10. Issue a short-lived app session cookie/JWT or validate `initData` on every API request.
Recommended TTL: 1 hour for write actions, configurable up to 24 hours for read-only dashboards.
### App roles
Minimum roles:
- `viewer`: boards/tasks read-only, logs redacted/truncated.
- `operator`: create/comment/assign/promote/block/unblock/archive/dispatch.
- `admin`: configure bridge target, manage board allowlist, view diagnostics.
Initial open-source release may implement allowlist-only authorization with `operator` for all allowed users and document RBAC as planned, but the API contract should reserve `role` fields now.
### Bridge authentication
Recommended:
- `Authorization: Bearer <bridge-token>` between BFF and bridge.
- HTTPS at the public edge if bridge is reachable from Vercel.
- Optional reverse-proxy IP allowlist, Cloudflare Access, Tailscale, or mTLS for advanced users.
- Bridge should reject requests without `X-Request-Id` and should log audit metadata without storing Telegram raw initData.
## 8. Threat model
| Threat | Risk | Mitigation |
|---|---|---|
| Browser steals Hermes API key | Full agent/tool compromise | Never put `API_SERVER_KEY` or bridge token in frontend env. Use BFF. |
| Forged Telegram user | Unauthorized board control | Validate initData server-side using official HMAC flow and max age. |
| Replay of old initData | Unauthorized action reuse | Enforce `auth_date` TTL; short-lived session; optional nonce/idempotency for writes. |
| CLI injection | Arbitrary local commands | Bridge maps endpoints to fixed argv; use `execFile`/`spawn` with `shell:false`; validate task IDs/board slugs. |
| Path traversal via board/workspace | Filesystem escape | Accept only Hermes-valid board slugs; never pass raw paths from browser except documented workspace kind with strict validation. |
| Leaking secrets in logs/errors | Token disclosure | Redact `token`, `secret`, `password`, `api_key`; truncate logs; avoid returning raw stderr. |
| CSRF against BFF | Actions from malicious site | Use SameSite cookies or Authorization header; verify Origin; require Telegram/session auth for writes. |
| CORS overexposure | Browser exfiltration | BFF CORS only for app origin; bridge CORS disabled by default. |
| SSRF from BFF to arbitrary bridge URL | Internal network probing | `BRIDGE_BASE_URL` env only; no user-provided target URLs. |
| Bridge public exposure | Local Hermes takeover | Strong token, HTTPS, rate limiting, reverse-proxy auth, optional local-only mode. |
| Denial of service by refresh/log tail | Resource exhaustion | Pagination, `limit` caps, debounce refresh, server-side rate limits, timeouts. |
| Confused deputy across boards | User controls wrong board | Server-side board allowlist; all write endpoints include normalized board slug and audit actor. |
| Supply-chain compromise | Public package risk | Pin lockfile, Dependabot/Renovate, CI tests, minimal dependencies. |
## 9. Deploy matrix
| Mode | Frontend | BFF | Bridge | Hermes access | Recommended for | Notes |
|---|---|---|---|---|---|---|
| Vercel + self-hosted bridge | Vercel static Vite | Vercel Node serverless routes | Node bridge on user's server/Pi/VPS | Bridge runs local `hermes kanban` | Community default | Best UX for Telegram HTTPS and simple updates. Requires exposing bridge securely. |
| Docker Compose single host | Containerized static frontend served by BFF or Nginx | Node BFF container | Node bridge container or same process | Bind-mount Hermes home / host `hermes` | Self-hosters | Strong docs needed for volumes and user permissions. Avoid mounting Docker socket. |
| Local dev | Vite dev server | Local Node BFF | Local bridge | Local `hermes kanban` | Contributors | Supports Telegram mock/dev auth. No public exposure. |
| Fully static Vercel only | Vercel static | None | None | None | Demo/readme only | Cannot control real Hermes. Can show mock data only. |
| Browser -> Hermes API direct | Static or local | None | None | Hermes API Server over HTTPS | Not recommended | Exposes bearer token to browser unless using a trusted private network. Document as unsafe for public/community default. |
| Future native Hermes Kanban API | Vercel static | Vercel BFF | Optional/none | BFF calls official `/api/kanban` | Future | Adopt only when official Hermes exposes least-privilege Kanban CRUD separate from full agent API. |
## 10. API contract for v1
All public frontend calls go to the BFF under `/api/*`. The BFF calls the bridge under `/bridge/v1/*` or equivalent. The frontend should not know whether data came from a bridge or future native Hermes API.
### Common headers
Frontend -> BFF:
```http
Authorization: tma <raw initData>
Content-Type: application/json
X-Request-Id: <uuid>
```
BFF -> Bridge:
```http
Authorization: Bearer <bridge-token>
Content-Type: application/json
X-Request-Id: <uuid>
X-Actor-Telegram-Id: <id>
X-Actor-Role: viewer|operator|admin
```
### Common response envelope
```ts
type ApiOk<T> = {
ok: true;
data: T;
requestId: string;
};
type ApiError = {
ok: false;
error: {
code: string;
message: string;
details?: unknown;
};
requestId: string;
};
```
### Core models
```ts
type Board = {
slug: string;
name?: string;
description?: string;
icon?: string;
current?: boolean;
};
type TaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived';
type TaskSummary = {
id: string;
title: string;
status: TaskStatus;
assignee?: string | null;
priority?: number | null;
createdAt?: string;
updatedAt?: string;
blockedReason?: string | null;
parentIds?: string[];
childIds?: string[];
};
type TaskDetail = TaskSummary & {
body?: string | null;
comments?: TaskComment[];
runs?: TaskRun[];
events?: TaskEvent[];
attachments?: TaskAttachment[];
};
type TaskComment = {
id?: string;
author?: string;
body: string;
createdAt?: string;
};
type TaskRun = {
id?: string;
status?: string;
startedAt?: string;
finishedAt?: string;
logAvailable?: boolean;
};
type TaskEvent = {
id?: string;
type: string;
message?: string;
createdAt?: string;
};
type TaskAttachment = {
id: string;
filename: string;
sizeBytes?: number;
mimeType?: string;
};
```
### BFF endpoints
Read:
- `GET /api/health` -> `{ status, version, bridge: { configured, reachable } }`
- `GET /api/me` -> current Telegram user + role + auth expiry
- `GET /api/boards` -> `Board[]`
- `GET /api/boards/:board/tasks?status=&assignee=&q=&limit=&cursor=` -> paginated `TaskSummary[]` grouped client-side or server-side
- `GET /api/boards/:board/tasks/:taskId` -> `TaskDetail`
- `GET /api/boards/:board/tasks/:taskId/log?lines=200` -> redacted log tail
- `GET /api/boards/:board/events/stream` -> SSE for future live updates; polling fallback required
Write:
- `POST /api/boards/:board/tasks`
- `POST /api/boards/:board/tasks/:taskId/comment`
- `POST /api/boards/:board/tasks/:taskId/assign`
- `POST /api/boards/:board/tasks/:taskId/promote`
- `POST /api/boards/:board/tasks/:taskId/block`
- `POST /api/boards/:board/tasks/:taskId/unblock`
- `POST /api/boards/:board/tasks/:taskId/archive`
- `POST /api/boards/:board/dispatch`
Write payload examples:
```json
{
"title": "Implement mobile task drawer",
"body": "Acceptance criteria...",
"assignee": "developer",
"priority": 3,
"workspaceKind": "worktree",
"workspacePath": null,
"goalMode": false,
"maxRuntimeSeconds": 3600,
"idempotencyKey": "optional-client-generated-key"
}
```
```json
{ "body": "Looks good, unblock after config is set." }
```
```json
{ "assignee": "reviewer" }
```
```json
{ "reason": "Needs maintainer decision on API shape." }
```
### Bridge endpoint behavior
Bridge can mirror BFF paths under `/bridge/v1/boards/...`, but it must be internal-only. It should return the same envelope. It must:
- Validate all slugs and IDs before spawning Hermes CLI.
- Use a command allowlist, not arbitrary pass-through.
- Set `HERMES_HOME` from env, not request payload.
- Set `HERMES_KANBAN_BOARD` or `--board` from validated board slug.
- Enforce timeouts per command.
- Redact and normalize errors.
## 11. UX requirements
Telegram Mini App:
- Respect Telegram theme variables and safe areas.
- Use Telegram-style components for lists, section headers, buttons, badges, bottom sheets/drawers, destructive confirmations.
- Use haptic feedback only for meaningful actions if SDK supports it.
- `MainButton`/bottom action area for primary task action on mobile.
- HashRouter or equivalent router compatible with Telegram webview and static hosting.
Browser/mobile:
- Responsive board: columns on desktop, status tabs/list on mobile.
- Fast read path: skeleton loading, optimistic comment append only after server accepts writes or clearly mark pending.
- Polling fallback; SSE enhancement later.
- Accessible color contrast and keyboard navigation in browser.
Community docs/quality:
- `.env.example` only placeholders.
- No real bot tokens, Hermes keys, domains, local IPs, or passwords.
- README in English plus Russian quickstart or `README.ru.md`.
- `SECURITY.md`, `CONTRIBUTING.md`, CI, tests, build checks.
## 12. Documentation outline
### English docs
1. `README.md`
- What is Hermes Kanban Mini App?
- Screenshots/GIFs
- Architecture summary
- Quickstart matrix: Vercel + bridge, Docker Compose, local dev
- Security warning: static frontend cannot run Hermes CLI
- Links to full docs
2. `docs/installation.en.md`
- Prerequisites: Node.js, Hermes Agent, Telegram bot, HTTPS URL
- Create Telegram bot and configure Mini App domain
- Vercel frontend/BFF deploy
- Self-hosted bridge setup
- Docker Compose setup
- Local dev setup with mock Telegram auth
3. `docs/configuration.en.md`
- All env vars
- Auth modes
- Board allowlist
- Rate limit settings
- Reverse proxy examples
4. `docs/security.en.md`
- Threat model
- Token handling
- Telegram initData validation
- Bridge exposure hardening
- Responsible disclosure
5. `docs/api.en.md`
- BFF and bridge endpoint contracts
- Error codes
- SSE/polling behavior
6. `docs/migration-from-fastapi-prototype.en.md`
- Mapping current prototype endpoints to new TS BFF/bridge
- Removed assumptions
- Compatibility notes
### Russian docs
1. `README.ru.md`
- Что это и кому нужно
- Рекомендуемая схема установки
- Главное предупреждение про Vercel/static и локальный Hermes CLI
2. `docs/installation.ru.md`
- Telegram BotFather
- Vercel
- self-hosted bridge
- Docker Compose
- локальный dev
3. `docs/configuration.ru.md`
- Переменные окружения
- режимы авторизации
- allowlist пользователей/досок
4. `docs/security.ru.md`
- модель угроз
- где хранить токены
- как безопасно открыть bridge
5. `docs/api.ru.md`
- контракт API
- ошибки
6. `docs/migration-from-fastapi-prototype.ru.md`
- план миграции с текущего прототипа
## 13. Environment variables for the new stack
Frontend build-time public vars:
```bash
VITE_APP_NAME=Hermes Kanban
VITE_BFF_BASE_URL= # empty for same-origin Vercel deployment
VITE_TELEGRAM_BOT_USERNAME= # optional display/link only, not token
```
Vercel BFF server vars:
```bash
TELEGRAM_BOT_TOKEN= # secret, server-only
TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram IDs
TELEGRAM_ADMIN_USER_IDS= # optional
SESSION_SECRET= # random 32+ bytes
BRIDGE_BASE_URL= # https://bridge.example.com
BRIDGE_TOKEN= # secret, server-to-server
ALLOWED_ORIGINS= # exact frontend origins
AUTH_MAX_AGE_SECONDS=3600
READ_AUTH_MAX_AGE_SECONDS=86400
RATE_LIMIT_PER_MINUTE=60
```
Bridge server vars:
```bash
BRIDGE_TOKEN= # must match BFF
HERMES_BIN=hermes
HERMES_HOME= # target Hermes profile home
HERMES_KANBAN_BOARD=default
BRIDGE_HOST=127.0.0.1
BRIDGE_PORT=8787
BRIDGE_ALLOWED_BOARDS=default
COMMAND_TIMEOUT_SECONDS=30
LOG_TAIL_MAX_LINES=500
```
Docker Compose vars should mirror the same names. `.env` must stay gitignored; `.env.example` must contain placeholders only.
## 14. Migration plan from current FastAPI prototype
Do not modify the current Python implementation until this ADR is accepted.
Phase 0: Freeze and document
- Keep current prototype uncommitted as reference only.
- Add this ADR.
- Record endpoint inventory and data shapes from `backend/app/main.py`, `adapter.py`, `auth.py`, and frontend calls.
Phase 1: Extract contracts
- Create shared TypeScript schemas for Board, TaskSummary, TaskDetail, API envelope, and write payloads.
- Write contract tests against fixtures copied from current prototype responses.
- Define error code taxonomy.
Phase 2: Build TypeScript bridge
- Implement Node bridge with fixed command allowlist matching current `HermesCliKanbanAdapter` behavior.
- Port Telegram-independent validation helpers: board slug, task ID, safe log limits, error redaction.
- Add tests that assert `shell:false` / `execFile` usage and reject invalid IDs/slugs.
- Verify against local `hermes kanban` commands.
Phase 3: Build Vercel BFF
- Implement Telegram initData validation using a maintained Node package such as `@tma.js/init-data-node` or a small audited HMAC helper with tests from Telegram examples.
- Add allowlist auth and bridge proxy endpoints.
- Add rate limiting and CORS/origin checks.
Phase 4: Rebuild frontend UX
- Keep React + TypeScript + Vite.
- Replace ad-hoc Telegram integration with Telegram Apps SDK React package.
- Add Telegram UI components, mobile task drawer, board status tabs, desktop columns.
- Use BFF API only; no direct bridge/Hermes calls from browser.
Phase 5: Docs and release hardening
- Write EN/RU docs from outline.
- Add `.env.example`, `SECURITY.md`, `CONTRIBUTING.md`, CI.
- Add unit tests, contract tests, frontend tests, build check.
- Add Docker Compose example.
- Add Vercel deployment guide.
Phase 6: Optional legacy compatibility
- Either remove Python prototype from the public release or move it to `legacy/fastapi-prototype` with clear unsupported status.
- Do not publish both as equal recommended paths; it confuses users.
## 15. Consequences
Positive:
- Clear Vercel-first story without lying about local CLI access.
- Secrets stay server-side.
- TypeScript types can be shared across frontend, BFF, and bridge.
- Bridge is optional but honest: users who want real Hermes control must run a trusted server near Hermes.
- Future Hermes native Kanban HTTP API can replace bridge internals without changing frontend UX.
Negative/tradeoffs:
- Requires two deploy targets for real use: Vercel app and bridge.
- Public bridge exposure needs careful docs.
- Slightly more moving parts than a single FastAPI container.
- Until Hermes exposes first-class Kanban HTTP endpoints, the bridge still depends on CLI JSON behavior.
## 16. Rejected alternatives
### Static Vercel app directly runs Hermes CLI
Rejected. Browsers/static hosting cannot execute the user's local CLI. Any workaround would require unsafe local agents or browser extensions and is not community-ready.
### Static app directly calls Hermes API Server with API key
Rejected for public/community default. Hermes API Server bearer key controls a full agent toolset including terminal; exposing it to JavaScript is a critical security issue.
### Keep single Python/FastAPI backend as recommended public stack
Rejected as recommended stack. It works locally and may be a useful prototype, but it conflicts with TypeScript/Vercel preference and mixes trust boundaries.
### Full Next.js monolith
Not selected. Next.js could host frontend+BFF on Vercel, but Vite static + small serverless BFF is simpler, lighter, and closer to official Telegram Mini Apps React template patterns. If future SSR/auth needs grow, migration to Next.js remains possible.
### Bridge reads `kanban.db` directly
Rejected for v1. Direct DB reads couple the project to Hermes internals and risk schema drift. Use official CLI behavior first; switch to official HTTP API when available.
## 17. Precise next implementation milestones
M1. Contract foundation
- Add `packages/shared` or `src/shared` TypeScript schemas.
- Add API envelope and error code tests.
- No UI rewrite yet.
M2. Bridge MVP
- Create `apps/bridge` TypeScript service.
- Implement `GET /health`, `GET /bridge/v1/boards`, `GET /bridge/v1/boards/:board/tasks`, `GET /bridge/v1/boards/:board/tasks/:taskId`.
- Use fixed `execFile`/`spawn` calls to `hermes kanban` with tests.
M3. BFF auth MVP
- Create Vercel API routes.
- Validate Telegram initData in server code.
- Proxy read endpoints to bridge.
- Add auth fixtures/tests.
M4. Frontend read-only UX
- Migrate current React UI to new BFF contract.
- Add Telegram SDK initialization and Telegram UI theme handling.
- Ship read-only boards/tasks/log tail.
M5. Write actions
- Add create/comment/assign/promote/block/unblock/archive/dispatch.
- Add confirmations and role checks.
- Add audit log fields passed to bridge.
M6. Release docs and CI
- EN/RU install docs.
- `.env.example` for frontend, BFF, bridge, Docker Compose.
- `SECURITY.md`, `CONTRIBUTING.md`.
- CI: typecheck, lint, unit tests, frontend build, bridge tests.
M7. Optional live updates
- SSE endpoint from BFF to frontend.
- Bridge polling or future Hermes dashboard/events integration.
- Graceful fallback to polling.
## 18. Acceptance criteria for starting app-code rewrite
Do not write implementation code until these are accepted:
- This ADR stack decision is approved.
- Maintainer confirms whether bridge is a separate `apps/bridge` service or same package as BFF with a runtime mode switch.
- Maintainer confirms whether the public repo should delete the Python prototype or keep it under `legacy/`.
- First milestone is limited to contracts + tests + scaffold, not feature-complete UI.

41
frontend/Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────
# Frontend — Multi-stage Docker build
# Stage 1: install deps + build with Vite
# Stage 2: serve static with nginx:alpine
# ─────────────────────────────────────────────────────────────
# ── Stage 1: Build ─────────────────────────────────────────
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Copy workspace scaffolding
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/contracts/package.json packages/contracts/
COPY frontend/package.json frontend/
# Install all dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY packages/contracts/ packages/contracts/
COPY frontend/ frontend/
# Build contracts (frontend depends on @kanban/contracts), then frontend
RUN pnpm --filter @kanban/contracts build && \
pnpm --filter frontend build
# ── Stage 2: Serve ─────────────────────────────────────────
FROM nginx:alpine AS runtime
# Remove default nginx config
RUN rm -f /etc/nginx/conf.d/default.conf
# Copy custom nginx config
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built frontend
COPY --from=build /app/frontend/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1 +1,12 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Hermes Kanban</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html> <!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Hermes Kanban</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/nginx.conf Normal file
View file

@ -0,0 +1,39 @@
# ─────────────────────────────────────────────────────────────
# nginx.conf Frontend container configuration
# Serves Vite SPA and proxies /bridge to the bridge service
# ─────────────────────────────────────────────────────────────
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy /bridge requests to the bridge Express service
location /bridge/ {
proxy_pass http://bridge:3456/bridge/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Timeouts for slow hermes CLI calls
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# SPA fallback serve index.html for client-side routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively (Vite hashes filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

File diff suppressed because it is too large Load diff

View file

@ -1 +1,29 @@
{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build","preview":"vite preview --host 0.0.0.0","test":"vitest run --environment jsdom"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest","lucide-react":"latest"},"devDependencies":{"vitest":"latest","jsdom":"latest","@testing-library/react":"latest","@testing-library/jest-dom":"latest","@testing-library/user-event":"latest","@types/react":"latest","@types/react-dom":"latest"}} {
"name": "frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@kanban/contracts": "workspace:*",
"@telegram-apps/sdk-react": "^3.3.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.6.3",
"vite": "^6.0.5"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,186 +1,253 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { RefreshCw, Plus, Send } from 'lucide-react' import type { Board, TaskSummary, TaskDetail, TaskListData, CreateTaskPayload, CommentPayload } from '@kanban/contracts'
import { api } from './api' import { DEFAULT_COLUMNS } from '@kanban/contracts'
import { applyTelegramTheme } from './telegram' import { initTelegram, applyTheme, hapticFeedback, showBackButton, hideBackButton } from './telegram'
import type { Event, Run, Task, TaskGroups } from './types' import { fetchBoards, fetchTasks, fetchTask, createTask, addComment } from './api/client'
import BoardSelector from './components/BoardSelector'
import KanbanBoard from './components/KanbanBoard'
import TaskDetailModal from './components/TaskDetailModal'
import CreateTaskForm from './components/CreateTaskForm'
const STATUSES = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived'] type View = 'boards' | 'board' | 'task' | 'create'
type Detail = {
task?: Task
events: Event[]
runs: Run[]
log: string
missingLog: boolean
}
function ErrorBanner({ message }: { message: string }) {
return <div className="error" role="alert">{message}</div>
}
function TaskCard({ task, onOpen }: { task: Task; onOpen: (task: Task) => void }) {
return (
<button className="task-card" onClick={() => onOpen(task)}>
<span className="task-id">{task.id}</span>
<strong>{task.title}</strong>
<span className="status-pill">status: {task.status}</span>
<span>{task.assignee || 'unassigned'}</span>
<span>priority {task.priority ?? 0}</span>
{task.age && <span>{task.age}</span>}
</button>
)
}
function CreateTask({ onCreated }: { onCreated: () => void }) {
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [assignee, setAssignee] = useState('')
const [priority, setPriority] = useState(0)
const [workspaceKind, setWorkspaceKind] = useState('scratch')
const [goalMode, setGoalMode] = useState(false)
const [maxRuntime, setMaxRuntime] = useState('')
const [error, setError] = useState('')
async function submit() {
if (!title.trim()) {
setError('Title is required')
return
}
setError('')
await api.create({
title,
body,
assignee: assignee || null,
priority,
workspace_kind: workspaceKind,
goal_mode: goalMode,
max_runtime_seconds: maxRuntime ? Number(maxRuntime) : null,
})
setOpen(false)
setTitle('')
setBody('')
onCreated()
}
if (!open) return <button className="primary" onClick={() => setOpen(true)}><Plus size={16} />Create task</button>
return (
<div className="modal-card">
<h2>Create task</h2>
{error && <ErrorBanner message={error} />}
<input placeholder="Title" value={title} onChange={(event) => setTitle(event.target.value)} />
<textarea placeholder="Body" value={body} onChange={(event) => setBody(event.target.value)} />
<input placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} />
<input aria-label="Priority" type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
<select value={workspaceKind} onChange={(event) => setWorkspaceKind(event.target.value)}>
<option value="scratch">scratch</option>
<option value="dir">dir</option>
<option value="worktree">worktree</option>
</select>
<label className="row"><input type="checkbox" checked={goalMode} onChange={(event) => setGoalMode(event.target.checked)} /> goal mode</label>
<input placeholder="Max runtime seconds" value={maxRuntime} onChange={(event) => setMaxRuntime(event.target.value)} />
<div className="row">
<button onClick={submit}>Create</button>
<button className="ghost" onClick={() => setOpen(false)}>Cancel</button>
</div>
</div>
)
}
function DetailDrawer({ selected, detail, onClose, onRefresh }: { selected: Task | null; detail: Detail | null; onClose: () => void; onRefresh: () => void }) {
const [comment, setComment] = useState('')
const [assign, setAssign] = useState('')
const [blockReason, setBlockReason] = useState('')
if (!selected) return null
const task = detail?.task || selected
async function addComment() {
if (!comment.trim()) return
await api.comment(selected!.id, comment)
setComment('')
onRefresh()
}
return (
<aside className="drawer">
<button className="close" onClick={onClose}>×</button>
<p className="task-id">{task.id}</p>
<h2>{task.title}</h2>
<p className="body">{task.body || 'No body'}</p>
<div className="actions">
<button onClick={() => api.action(task.id, 'promote').then(onRefresh)}>Promote</button>
<button onClick={() => api.action(task.id, 'unblock').then(onRefresh)}>Unblock</button>
<button onClick={() => api.action(task.id, 'archive').then(onRefresh)}>Archive</button>
</div>
<div className="row"><input placeholder="Assign to" value={assign} onChange={(event) => setAssign(event.target.value)} /><button onClick={() => api.assign(task.id, assign).then(onRefresh)}>Assign</button></div>
<div className="row"><input placeholder="Block reason" value={blockReason} onChange={(event) => setBlockReason(event.target.value)} /><button onClick={() => api.block(task.id, blockReason).then(onRefresh)}>Block</button></div>
<section><h3>Comments</h3><div className="row"><input placeholder="Comment" value={comment} onChange={(event) => setComment(event.target.value)} /><button onClick={addComment}><Send size={14} /></button></div></section>
<section><h3>Events</h3><pre>{JSON.stringify(detail?.events || [], null, 2)}</pre></section>
<section><h3>Runs</h3><pre>{JSON.stringify(detail?.runs || [], null, 2)}</pre></section>
<section><h3>Log</h3><button onClick={onRefresh}>Refresh log</button><pre>{detail?.missingLog ? 'No log yet' : detail?.log}</pre></section>
</aside>
)
}
export default function App() { export default function App() {
const [groups, setGroups] = useState<TaskGroups>({}) const [view, setView] = useState<View>('boards')
const [error, setError] = useState('') const [boards, setBoards] = useState<Board[]>([])
const [selectedBoard, setSelectedBoard] = useState<Board | null>(null)
const [tasks, setTasks] = useState<TaskListData | null>(null)
const [selectedTask, setSelectedTask] = useState<TaskDetail | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<Task | null>(null) const [error, setError] = useState<string | null>(null)
const [detail, setDetail] = useState<Detail | null>(null)
const hasRunningTasks = useMemo(() => (groups.running || []).length > 0, [groups])
async function loadBoard() { // Initialize Telegram on mount
useEffect(() => {
initTelegram()
applyTheme()
}, [])
// Load boards
const loadBoards = useCallback(async () => {
try { try {
const data = await api.tasks() setLoading(true)
setGroups(data.groups) setError(null)
setError('') const data = await fetchBoards()
setBoards(data)
// Auto-select if only one board
if (data.length === 1) {
setSelectedBoard(data[0])
setView('board')
} else {
setView('boards')
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load board') setError(err instanceof Error ? err.message : 'Failed to load boards')
} finally { } finally {
setLoading(false) setLoading(false)
} }
}
async function openTask(task: Task) {
setSelected(task)
const [taskData, events, runs, log] = await Promise.all([api.task(task.id), api.events(task.id), api.runs(task.id), api.log(task.id)])
setDetail({ task: taskData.task, events: events.events, runs: runs.runs, log: log.log, missingLog: log.missing })
}
async function refreshSelected() {
await loadBoard()
if (selected) await openTask(selected)
}
useEffect(() => {
applyTelegramTheme()
loadBoard()
}, []) }, [])
// Load tasks for board
const loadTasks = useCallback(async (board: string) => {
try {
setLoading(true)
setError(null)
const data = await fetchTasks(board)
setTasks(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tasks')
} finally {
setLoading(false)
}
}, [])
// Load task detail
const loadTaskDetail = useCallback(async (board: string, taskId: string) => {
try {
const detail = await fetchTask(board, taskId)
setSelectedTask(detail)
setView('task')
showBackButton(() => {
setSelectedTask(null)
setView('board')
hideBackButton()
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load task')
}
}, [])
// Initial load
useEffect(() => { useEffect(() => {
const interval = window.setInterval(loadBoard, hasRunningTasks ? 2500 : 5000) loadBoards()
return () => window.clearInterval(interval) }, [loadBoards])
}, [hasRunningTasks])
// Load tasks when board changes
useEffect(() => {
if (selectedBoard) {
loadTasks(selectedBoard.slug)
}
}, [selectedBoard, loadTasks])
// Handle board selection
const handleSelectBoard = (board: Board) => {
hapticFeedback('light')
setSelectedBoard(board)
setView('board')
}
// Handle task tap
const handleTaskTap = (task: TaskSummary) => {
hapticFeedback('light')
if (selectedBoard) {
loadTaskDetail(selectedBoard.slug, task.id)
}
}
// Handle create task
const handleCreateTask = async (payload: CreateTaskPayload) => {
if (!selectedBoard) return
try {
hapticFeedback('medium')
await createTask(selectedBoard.slug, payload)
setView('board')
loadTasks(selectedBoard.slug)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task')
}
}
// Handle add comment
const handleAddComment = async (taskId: string, payload: CommentPayload) => {
if (!selectedBoard) return
try {
hapticFeedback('light')
await addComment(selectedBoard.slug, taskId, payload)
// Reload task detail
const detail = await fetchTask(selectedBoard.slug, taskId)
setSelectedTask(detail)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add comment')
}
}
// Handle back
const handleBack = () => {
hapticFeedback('light')
if (view === 'task') {
setSelectedTask(null)
setView('board')
hideBackButton()
} else if (view === 'create') {
setView('board')
hideBackButton()
} else if (view === 'board' && boards.length > 1) {
setSelectedBoard(null)
setView('boards')
}
}
// Handle open create form
const handleOpenCreate = () => {
hapticFeedback('medium')
setView('create')
showBackButton(handleBack)
}
// Loading state
if (loading && !tasks) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-400">Loading</p>
</div>
</div>
)
}
// Error state
if (error && !tasks) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="text-center">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadBoards}
className="px-4 py-2 bg-blue-600 rounded-lg text-white"
>
Retry
</button>
</div>
</div>
)
}
return ( return (
<main className="app-shell"> <div className="min-h-screen flex flex-col">
<header className="topbar"> {/* Header */}
<div><p className="eyebrow">Telegram Mini App</p><h1>Hermes Kanban</h1></div> <header className="sticky top-0 z-10 bg-[var(--tg-secondary-bg-color,#1a1f2e)] border-b border-white/10 px-4 py-3">
<div className="row"><CreateTask onCreated={loadBoard} /><button className="ghost" onClick={loadBoard}><RefreshCw size={16} />Refresh</button><button className="ghost" onClick={() => api.dispatch().then(loadBoard)}>Dispatch</button></div> <div className="flex items-center justify-between">
{view !== 'boards' && (
<button
onClick={handleBack}
className="p-2 -ml-2 text-blue-400"
>
Back
</button>
)}
<h1 className="text-lg font-semibold flex-1 text-center">
{view === 'boards' ? 'Select Board' : selectedBoard?.name || selectedBoard?.slug || 'Kanban'}
</h1>
{view === 'board' && (
<button
onClick={handleOpenCreate}
className="p-2 -mr-2 text-blue-400 font-medium"
>
+ New
</button>
)}
{view === 'boards' && <div className="w-10" />}
</div>
</header> </header>
{error && <ErrorBanner message={error} />}
{loading ? <p>Loading</p> : ( {/* Error banner */}
<section className="board"> {error && (
{STATUSES.map((status) => ( <div className="mx-4 mt-3 p-3 bg-red-900/30 border border-red-500/30 rounded-lg text-red-300 text-sm">
<div className="column" key={status}> {error}
<h2>{status}</h2> <button onClick={() => setError(null)} className="ml-2 text-red-400"></button>
{(groups[status] || []).map((task) => <TaskCard key={task.id} task={task} onOpen={openTask} />)} </div>
{(groups[status] || []).length === 0 && <p className="empty">No tasks</p>}
</div>
))}
</section>
)} )}
<DetailDrawer selected={selected} detail={detail} onClose={() => setSelected(null)} onRefresh={refreshSelected} />
</main> {/* Main content */}
<main className="flex-1 overflow-hidden">
{view === 'boards' && (
<BoardSelector boards={boards} onSelect={handleSelectBoard} />
)}
{view === 'board' && tasks && (
<KanbanBoard
tasks={tasks.items}
columns={DEFAULT_COLUMNS}
onTaskTap={handleTaskTap}
onRefresh={() => selectedBoard && loadTasks(selectedBoard.slug)}
/>
)}
{view === 'task' && selectedTask && (
<TaskDetailModal
task={selectedTask}
onClose={handleBack}
onAddComment={(payload) => handleAddComment(selectedTask.id, payload)}
/>
)}
{view === 'create' && (
<CreateTaskForm
onSubmit={handleCreateTask}
onCancel={handleBack}
/>
)}
</main>
</div>
) )
} }

View file

@ -1,76 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
import App from '../App'
const tasksResponse = {
tasks: [
{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5, age: '1m' },
],
groups: {
triage: [],
todo: [],
ready: [{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5 }],
running: [],
blocked: [],
done: [],
archived: [],
},
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).includes('/api/tasks/t_1/events')) return new Response(JSON.stringify({ events: [{ kind: 'created' }] }))
if (String(url).includes('/api/tasks/t_1/runs')) return new Response(JSON.stringify({ runs: [{ id: 1, status: 'completed' }] }))
if (String(url).includes('/api/tasks/t_1/log')) return new Response(JSON.stringify({ log: 'tail', missing: false }))
if (String(url).endsWith('/api/tasks/t_1')) return new Response(JSON.stringify({ task: { ...tasksResponse.tasks[0], body: 'Full body' } }))
if (String(url).includes('/api/tasks') && init?.method === 'POST') return new Response(JSON.stringify({ task: { id: 't_new', title: 'Created', status: 'todo' } }))
if (String(url).includes('/api/tasks')) return new Response(JSON.stringify(tasksResponse))
return new Response(JSON.stringify({ status: 'ok' }))
}))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('App', () => {
it('renders board columns', async () => {
render(<App />)
expect(await screen.findByText('ready')).toBeInTheDocument()
expect(screen.getByText('blocked')).toBeInTheDocument()
})
it('renders task cards with title status and assignee', async () => {
render(<App />)
expect(await screen.findByText('Build thing')).toBeInTheDocument()
expect(screen.getByText('developer')).toBeInTheDocument()
})
it('opens detail drawer and shows body events and log sections', async () => {
render(<App />)
fireEvent.click(await screen.findByText('Build thing'))
expect(await screen.findByText('Full body')).toBeInTheDocument()
expect(screen.getByText('Events')).toBeInTheDocument()
expect(screen.getByText('Log')).toBeInTheDocument()
})
it('create form validates title', async () => {
render(<App />)
fireEvent.click(await screen.findByText('Create task'))
fireEvent.click(screen.getByText('Create'))
expect(await screen.findByText('Title is required')).toBeInTheDocument()
})
it('shows API error state', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ detail: { message: 'boom' } }), { status: 500 })))
render(<App />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
})
it('does not crash when Telegram WebApp is absent', async () => {
vi.stubGlobal('Telegram', undefined)
render(<App />)
await waitFor(() => expect(screen.getByText('Hermes Kanban')).toBeInTheDocument())
})
})

View file

@ -1,31 +0,0 @@
import { getTelegramInitData } from './telegram'
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Content-Type', 'application/json')
const initData = getTelegramInitData()
if (initData) headers.set('X-Telegram-Init-Data', initData)
const response = await fetch(`${API_BASE}${path}`, { ...init, headers })
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const message = data?.detail?.message || data?.detail || response.statusText || 'API request failed'
throw new Error(String(message))
}
return data as T
}
export const api = {
tasks: () => request<{ tasks: any[]; groups: Record<string, any[]> }>('/api/tasks'),
task: (id: string) => request<{ task: any }>(`/api/tasks/${id}`),
events: (id: string) => request<{ events: any[] }>(`/api/tasks/${id}/events`),
runs: (id: string) => request<{ runs: any[] }>(`/api/tasks/${id}/runs`),
log: (id: string) => request<{ log: string; missing: boolean }>(`/api/tasks/${id}/log`),
create: (payload: Record<string, unknown>) => request<{ task: any }>('/api/tasks', { method: 'POST', body: JSON.stringify(payload) }),
comment: (id: string, body: string) => request(`/api/tasks/${id}/comment`, { method: 'POST', body: JSON.stringify({ body }) }),
action: (id: string, action: 'promote' | 'unblock' | 'archive') => request(`/api/tasks/${id}/${action}`, { method: 'POST' }),
block: (id: string, reason: string) => request(`/api/tasks/${id}/block`, { method: 'POST', body: JSON.stringify({ reason }) }),
assign: (id: string, assignee: string) => request(`/api/tasks/${id}/assign`, { method: 'POST', body: JSON.stringify({ assignee }) }),
dispatch: () => request('/api/dispatch', { method: 'POST' }),
}

View file

@ -0,0 +1,97 @@
import type {
Board,
BoardListData,
TaskSummary,
TaskDetail,
TaskListData,
CreateTaskPayload,
CommentPayload,
ApiResponse,
HealthData,
} from '@kanban/contracts'
import { getInitData } from '../telegram'
const BRIDGE_URL = import.meta.env.VITE_BRIDGE_URL || '/bridge'
const AUTH_TOKEN: string = import.meta.env.VITE_AUTH_TOKEN || ''
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Content-Type', 'application/json')
// Auth: prefer X-Bridge-Token, fall back to Telegram initData
if (AUTH_TOKEN) {
headers.set('X-Bridge-Token', AUTH_TOKEN)
} else {
const initData = getInitData()
if (initData) {
headers.set('X-Bridge-Token', initData)
}
}
const url = `${BRIDGE_URL}${path}`
const response = await fetch(url, { ...init, headers })
const data = await response.json().catch(() => null)
if (!response.ok || !data) {
const message =
(data as { error?: { message?: string } })?.error?.message ||
response.statusText ||
`HTTP ${response.status}`
throw new Error(message)
}
const envelope = data as ApiResponse<T>
if (!envelope.ok) {
throw new Error(envelope.error?.message || 'API error')
}
return envelope.data
}
// ─── API methods ────────────────────────────────────────────
export async function fetchHealth(): Promise<HealthData> {
return request<HealthData>('/v1/health')
}
export async function fetchBoards(): Promise<Board[]> {
return request<BoardListData>('/v1/boards')
}
export async function fetchTasks(board: string): Promise<TaskListData> {
return request<TaskListData>(`/v1/boards/${encodeURIComponent(board)}/tasks`)
}
export async function fetchTask(board: string, taskId: string): Promise<TaskDetail> {
return request<TaskDetail>(
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}`
)
}
export async function createTask(
board: string,
payload: CreateTaskPayload
): Promise<TaskDetail> {
return request<TaskDetail>(
`/v1/boards/${encodeURIComponent(board)}/tasks`,
{
method: 'POST',
body: JSON.stringify(payload),
}
)
}
export async function addComment(
board: string,
taskId: string,
payload: CommentPayload
): Promise<void> {
await request<void>(
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}/comments`,
{
method: 'POST',
body: JSON.stringify(payload),
}
)
}

View file

@ -0,0 +1,41 @@
import type { Board } from '@kanban/contracts'
interface Props {
boards: Board[]
onSelect: (board: Board) => void
}
export default function BoardSelector({ boards, onSelect }: Props) {
return (
<div className="p-4 space-y-3">
<p className="text-gray-400 text-sm mb-4">Choose a board to view:</p>
{boards.map((board) => (
<button
key={board.slug}
onClick={() => onSelect(board)}
className="w-full text-left p-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-3">
{board.icon && (
<span className="text-2xl">{board.icon}</span>
)}
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-white truncate">
{board.name || board.slug}
</h2>
{board.description && (
<p className="text-sm text-gray-400 truncate mt-1">
{board.description}
</p>
)}
</div>
<span className="text-gray-500"></span>
</div>
</button>
))}
{boards.length === 0 && (
<p className="text-center text-gray-500 py-8">No boards available</p>
)}
</div>
)
}

View file

@ -0,0 +1,127 @@
import { useState } from 'react'
import type { CreateTaskPayload } from '@kanban/contracts'
import { hapticFeedback } from '../telegram'
interface Props {
onSubmit: (payload: CreateTaskPayload) => void
onCancel: () => void
}
export default function CreateTaskForm({ onSubmit, onCancel }: Props) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [assignee, setAssignee] = useState('')
const [priority, setPriority] = useState(0)
const [error, setError] = useState('')
const handleSubmit = () => {
if (!title.trim()) {
setError('Title is required')
hapticFeedback('heavy')
return
}
hapticFeedback('medium')
onSubmit({
title: title.trim(),
body: body.trim() || undefined,
assignee: assignee.trim() || undefined,
priority: priority || undefined,
})
}
return (
<div className="flex-1 overflow-y-auto p-4 pb-20">
<h2 className="text-lg font-semibold text-white mb-4">Create Task</h2>
{error && (
<div className="mb-4 p-3 bg-red-900/20 border border-red-500/20 rounded-lg text-red-300 text-sm">
{error}
</div>
)}
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to be done?"
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
autoFocus
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">
Description
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Add details…"
rows={4}
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
{/* Assignee */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">
Assignee
</label>
<input
type="text"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
placeholder="Username"
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Priority
</label>
<div className="flex gap-2">
{[0, 1, 2, 3, 4, 5].map((p) => (
<button
key={p}
onClick={() => setPriority(p)}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${
priority === p
? 'bg-blue-600 text-white'
: 'bg-white/5 text-gray-400 hover:bg-white/10'
}`}
>
{p === 0 ? 'None' : p}
</button>
))}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 mt-6">
<button
onClick={onCancel}
className="flex-1 py-3 bg-white/5 border border-white/10 rounded-lg text-gray-300 font-medium hover:bg-white/10 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
className="flex-1 py-3 bg-blue-600 rounded-lg text-white font-medium hover:bg-blue-500 transition-colors"
>
Create Task
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,89 @@
import type { TaskSummary, TaskStatus, ColumnDef } from '@kanban/contracts'
import TaskCard from './TaskCard'
interface Props {
tasks: TaskSummary[]
columns: ColumnDef[]
onTaskTap: (task: TaskSummary) => void
onRefresh: () => void
}
export default function KanbanBoard({ tasks, columns, onTaskTap, onRefresh }: Props) {
// Group tasks by status
const grouped: Record<TaskStatus, TaskSummary[]> = {} as Record<TaskStatus, TaskSummary[]>
for (const col of columns) {
grouped[col.status] = []
}
for (const task of tasks) {
const status = task.status as TaskStatus
if (!grouped[status]) {
grouped[status] = []
}
grouped[status].push(task)
}
return (
<div className="flex-1 overflow-x-auto">
<div className="flex gap-3 p-4 min-w-max pb-20">
{columns.map((col) => (
<div
key={col.status}
className="flex-shrink-0 w-[280px] flex flex-col"
>
{/* Column header */}
<div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-2">
<StatusDot status={col.status} />
<h3 className="font-medium text-sm text-gray-300 uppercase tracking-wider">
{col.title}
</h3>
</div>
<span className="text-xs text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
{grouped[col.status]?.length || 0}
</span>
</div>
{/* Column content */}
<div className="flex-1 space-y-2 min-h-[200px]">
{grouped[col.status]?.map((task) => (
<TaskCard
key={task.id}
task={task}
onTap={() => onTaskTap(task)}
/>
))}
{(!grouped[col.status] || grouped[col.status].length === 0) && (
<div className="text-center py-8 text-gray-600 text-sm">
No tasks
</div>
)}
</div>
</div>
))}
</div>
{/* Pull to refresh hint */}
<button
onClick={onRefresh}
className="fixed bottom-4 right-4 p-3 bg-blue-600 rounded-full shadow-lg z-10"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
</button>
</div>
)
}
function StatusDot({ status }: { status: TaskStatus }) {
const colors: Record<TaskStatus, string> = {
triage: 'bg-gray-400',
todo: 'bg-blue-400',
ready: 'bg-yellow-400',
running: 'bg-green-400',
blocked: 'bg-red-400',
done: 'bg-emerald-400',
archived: 'bg-gray-500',
}
return <div className={`w-2 h-2 rounded-full ${colors[status] || 'bg-gray-400'}`} />
}

View file

@ -0,0 +1,68 @@
import type { TaskSummary, TaskStatus } from '@kanban/contracts'
interface Props {
task: TaskSummary
onTap: () => void
}
export default function TaskCard({ task, onTap }: Props) {
return (
<button
onClick={onTap}
className="w-full text-left p-3 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors active:scale-[0.98]"
>
<div className="space-y-2">
{/* Title */}
<h4 className="font-medium text-white text-sm leading-snug line-clamp-2">
{task.title}
</h4>
{/* Meta row */}
<div className="flex items-center justify-between gap-2">
<StatusBadge status={task.status as TaskStatus} />
{task.assignee && (
<span className="text-xs text-gray-400 truncate">
@{task.assignee}
</span>
)}
</div>
{/* Priority */}
{task.priority != null && task.priority > 0 && (
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
<span key={i} className="text-orange-400 text-xs"></span>
))}
</div>
)}
{/* Blocked reason */}
{task.blockedReason && (
<p className="text-xs text-red-400 line-clamp-1">
{task.blockedReason}
</p>
)}
</div>
</button>
)
}
function StatusBadge({ status }: { status: TaskStatus }) {
const config: Record<TaskStatus, { bg: string; text: string; label: string }> = {
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
}
const { bg, text, label } = config[status] || config.todo
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
{label}
</span>
)
}

View file

@ -0,0 +1,212 @@
import { useState } from 'react'
import type { TaskDetail, TaskComment, CommentPayload } from '@kanban/contracts'
import { hapticFeedback } from '../telegram'
interface Props {
task: TaskDetail
onClose: () => void
onAddComment: (payload: CommentPayload) => void
}
export default function TaskDetailModal({ task, onClose, onAddComment }: Props) {
const [commentText, setCommentText] = useState('')
const [showCommentForm, setShowCommentForm] = useState(false)
const handleSubmitComment = () => {
if (!commentText.trim()) return
hapticFeedback('medium')
onAddComment({ body: commentText.trim() })
setCommentText('')
setShowCommentForm(false)
}
return (
<div className="flex-1 overflow-y-auto pb-20">
{/* Task header */}
<div className="p-4 border-b border-white/10">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-white leading-snug">
{task.title}
</h2>
<div className="flex items-center gap-2 mt-2">
<StatusBadge status={task.status} />
{task.assignee && (
<span className="text-sm text-gray-400">@{task.assignee}</span>
)}
</div>
</div>
</div>
{/* Priority */}
{task.priority != null && task.priority > 0 && (
<div className="flex items-center gap-1 mt-3">
<span className="text-xs text-gray-500">Priority:</span>
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
<span key={i} className="text-orange-400"></span>
))}
</div>
)}
{/* Blocked reason */}
{task.blockedReason && (
<div className="mt-3 p-3 bg-red-900/20 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-300">
<span className="font-medium">Blocked:</span> {task.blockedReason}
</p>
</div>
)}
</div>
{/* Description */}
{task.body && (
<div className="p-4 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400 mb-2">Description</h3>
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
{task.body}
</p>
</div>
)}
{/* Comments section */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-400">
Comments ({task.comments?.length || 0})
</h3>
<button
onClick={() => setShowCommentForm(!showCommentForm)}
className="text-sm text-blue-400 hover:text-blue-300"
>
{showCommentForm ? 'Cancel' : '+ Add'}
</button>
</div>
{/* Comment form */}
{showCommentForm && (
<div className="mb-4 p-3 bg-white/5 rounded-lg border border-white/10">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Write a comment…"
className="w-full bg-transparent border-0 outline-none resize-none text-sm text-white placeholder-gray-500 min-h-[80px]"
autoFocus
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setShowCommentForm(false)}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white"
>
Cancel
</button>
<button
onClick={handleSubmitComment}
disabled={!commentText.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</div>
)}
{/* Comments list */}
{task.comments && task.comments.length > 0 ? (
<div className="space-y-3">
{task.comments.map((comment, i) => (
<CommentItem key={comment.id || i} comment={comment} />
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
No comments yet
</p>
)}
</div>
{/* Events */}
{task.events && task.events.length > 0 && (
<div className="p-4 border-t border-white/10">
<h3 className="text-sm font-medium text-gray-400 mb-3">Activity</h3>
<div className="space-y-2">
{task.events.map((event, i) => (
<div key={event.id || i} className="flex items-start gap-2 text-sm">
<span className="text-gray-600 mt-0.5"></span>
<div>
<span className="text-gray-300">{event.type}</span>
{event.message && (
<span className="text-gray-500 ml-1"> {event.message}</span>
)}
{event.createdAt && (
<span className="text-gray-600 text-xs ml-2">
{new Date(event.createdAt).toLocaleTimeString()}
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Runs */}
{task.runs && task.runs.length > 0 && (
<div className="p-4 border-t border-white/10">
<h3 className="text-sm font-medium text-gray-400 mb-3">Runs</h3>
<div className="space-y-2">
{task.runs.map((run, i) => (
<div key={run.id || i} className="p-2 bg-white/5 rounded-lg text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-300">{run.status || 'unknown'}</span>
{run.startedAt && (
<span className="text-gray-500 text-xs">
{new Date(run.startedAt).toLocaleString()}
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
function CommentItem({ comment }: { comment: TaskComment }) {
return (
<div className="p-3 bg-white/5 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-white">
{comment.author || 'Anonymous'}
</span>
{comment.createdAt && (
<span className="text-xs text-gray-500">
{new Date(comment.createdAt).toLocaleString()}
</span>
)}
</div>
<p className="text-sm text-gray-300 leading-relaxed">{comment.body}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { bg: string; text: string; label: string }> = {
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
}
const { bg, text, label } = config[status] || config.todo
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
{label}
</span>
)
}

45
frontend/src/index.css Normal file
View file

@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tg-bg-color: #0f1420;
--tg-text-color: #f5f7fb;
--tg-hint-color: #9aa4b2;
--tg-link-color: #3b82f6;
--tg-button-color: #3b82f6;
--tg-button-text-color: #ffffff;
--tg-secondary-bg-color: #1a1f2e;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--tg-bg-color);
color: var(--tg-text-color);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}

View file

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './styles.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
) )

View file

@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest'

View file

@ -1,45 +0,0 @@
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--tg-bg-color, #10131a);
color: var(--tg-text-color, #f5f7fb);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, textarea, select { font: inherit; }
button { border: 0; border-radius: 12px; padding: .7rem .9rem; cursor: pointer; background: #242b38; color: inherit; display: inline-flex; align-items: center; gap: .4rem; }
button:hover { filter: brightness(1.12); }
input, textarea, select { width: 100%; border-radius: 12px; border: 1px solid #374151; padding: .75rem; background: #151a23; color: inherit; }
textarea { min-height: 110px; resize: vertical; }
pre { white-space: pre-wrap; word-break: break-word; background: #0b0e14; border: 1px solid #242b38; border-radius: 12px; padding: .8rem; max-height: 240px; overflow: auto; }
.app-shell { padding: 1rem; max-width: 1500px; margin: 0 auto; }
.topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.4rem); }
h2, h3 { margin: .3rem 0 .8rem; }
.eyebrow { color: var(--tg-hint-color, #9aa4b2); margin: 0; text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; }
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.primary { background: var(--tg-button-color, #3b82f6); color: var(--tg-button-text-color, white); }
.ghost { background: transparent; border: 1px solid #374151; }
.error { background: #431d23; color: #fecdd3; border: 1px solid #f43f5e; border-radius: 14px; padding: .8rem 1rem; margin: .8rem 0; }
.board { display: grid; grid-template-columns: repeat(7, minmax(220px, 1fr)); gap: .9rem; overflow-x: auto; padding-bottom: 1rem; }
.column { background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; min-height: 50vh; padding: .9rem; }
.column h2 { text-transform: lowercase; font-size: 1rem; color: var(--tg-accent-text-color, #93c5fd); }
.task-card { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; gap: .35rem .5rem; margin-bottom: .75rem; border: 1px solid rgba(255,255,255,.08); background: #171c26; }
.task-card strong { grid-column: 1 / -1; }
.task-id, .empty { color: var(--tg-hint-color, #9aa4b2); font-size: .82rem; }
.status-pill { border-radius: 999px; background: #1d4ed8; padding: .1rem .5rem; font-size: .78rem; justify-self: end; }
.modal-card { position: fixed; z-index: 4; right: 1rem; top: 5rem; width: min(440px, calc(100vw - 2rem)); display: grid; gap: .6rem; padding: 1rem; border-radius: 20px; background: #111827; border: 1px solid #374151; box-shadow: 0 20px 80px rgba(0,0,0,.45); }
.drawer { position: fixed; inset: 0 0 0 auto; width: min(560px, 100vw); background: #0f1420; border-left: 1px solid #374151; padding: 1rem; overflow-y: auto; z-index: 3; box-shadow: -20px 0 80px rgba(0,0,0,.35); }
.close { float: right; border-radius: 50%; width: 40px; height: 40px; justify-content: center; }
.body { color: #d1d5db; line-height: 1.55; }
.actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
@media (max-width: 800px) {
.app-shell { padding: .75rem; }
.board { grid-template-columns: repeat(7, 82vw); }
.topbar { align-items: stretch; }
.topbar .row, .topbar button { width: 100%; justify-content: center; }
}

View file

@ -1,31 +1,103 @@
// Telegram WebApp integration
// Uses window.Telegram.WebApp directly for maximum compatibility
// @telegram-apps/sdk-react provides typed wrappers but we keep a thin layer here
export function initTelegram(): void {
const tg = window.Telegram?.WebApp
if (tg) {
tg.ready()
tg.expand()
}
}
export function getInitData(): string {
return window.Telegram?.WebApp?.initData || ''
}
export function getColorScheme(): 'light' | 'dark' {
return window.Telegram?.WebApp?.colorScheme || 'dark'
}
export function getThemeParams(): Record<string, string> {
return window.Telegram?.WebApp?.themeParams || {}
}
export function hapticFeedback(type: 'light' | 'medium' | 'heavy' = 'medium'): void {
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
}
export function showMainButton(text: string, onClick: () => void): void {
const btn = window.Telegram?.WebApp?.MainButton
if (btn) {
btn.setText(text)
btn.show()
btn.onClick(onClick)
}
}
export function hideMainButton(): void {
window.Telegram?.WebApp?.MainButton?.hide()
}
export function showBackButton(onClick: () => void): void {
const btn = window.Telegram?.WebApp?.BackButton
if (btn) {
btn.show()
btn.onClick(onClick)
}
}
export function hideBackButton(): void {
window.Telegram?.WebApp?.BackButton?.hide()
}
export function closeMiniApp(): void {
window.Telegram?.WebApp?.close()
}
// Apply Telegram theme CSS variables
export function applyTheme(): void {
const params = getThemeParams()
const root = document.documentElement
Object.entries(params).forEach(([key, value]) => {
if (typeof value === 'string') {
root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
}
})
const scheme = getColorScheme()
root.dataset.theme = scheme
}
// Augment Window for TypeScript
declare global { declare global {
interface Window { interface Window {
Telegram?: { Telegram?: {
WebApp?: { WebApp?: {
ready?: () => void ready: () => void
expand?: () => void expand: () => void
initData?: string close: () => void
colorScheme?: 'light' | 'dark' initData: string
themeParams?: Record<string, string> colorScheme: 'light' | 'dark'
themeParams: Record<string, string>
HapticFeedback?: {
impactOccurred: (type: 'light' | 'medium' | 'heavy') => void
notificationOccurred: (type: 'error' | 'success' | 'warning') => void
selectionChanged: () => void
}
MainButton?: {
setText: (text: string) => void
show: () => void
hide: () => void
onClick: (cb: () => void) => void
offClick: (cb: () => void) => void
}
BackButton?: {
show: () => void
hide: () => void
onClick: (cb: () => void) => void
offClick: (cb: () => void) => void
}
} }
} }
} }
} }
export function getTelegramInitData(): string | undefined {
return window.Telegram?.WebApp?.initData || undefined
}
export function applyTelegramTheme(): void {
const webApp = window.Telegram?.WebApp
webApp?.ready?.()
webApp?.expand?.()
const theme = webApp?.themeParams || {}
const root = document.documentElement
Object.entries(theme).forEach(([key, value]) => {
if (typeof value === 'string') root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
})
if (webApp?.colorScheme) root.dataset.theme = webApp.colorScheme
}
export {}

View file

@ -1,18 +0,0 @@
export type KanbanStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
export type Task = {
id: string
title: string
status: KanbanStatus | string
assignee?: string | null
priority?: number | null
age?: string | null
body?: string | null
created_at?: number | null
started_at?: number | null
}
export type TaskGroups = Record<string, Task[]>
export type Event = Record<string, unknown> & { kind?: string }
export type Run = Record<string, unknown> & { id?: number | string; status?: string }

View file

@ -1,3 +1,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.css' interface ImportMetaEnv {
readonly VITE_BRIDGE_URL: string
readonly VITE_AUTH_TOKEN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -14,8 +14,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx"
"types": ["vitest/globals", "@testing-library/jest-dom"]
}, },
"include": ["src"] "include": ["src"]
} }

View file

@ -3,13 +3,12 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
setupFiles: './src/setupTests.ts',
environment: 'jsdom',
},
server: { server: {
proxy: { proxy: {
'/api': 'http://127.0.0.1:8000', '/bridge': {
target: 'http://127.0.0.1:3456',
changeOrigin: true,
},
}, },
}, },
}) })

View file

@ -0,0 +1,49 @@
# ─────────────────────────────────────────────────────────────
# @kanban/bridge — Multi-stage Docker build
# Stage 1: install deps + build TypeScript
# Stage 2: production runtime with node
# ─────────────────────────────────────────────────────────────
# ── Stage 1: Build ─────────────────────────────────────────
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Copy workspace scaffolding
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/contracts/package.json packages/contracts/
COPY packages/bridge/package.json packages/bridge/
# Install all dependencies (including dev for build)
RUN pnpm install --frozen-lockfile
# Copy source code
COPY packages/contracts/ packages/contracts/
COPY packages/bridge/ packages/bridge/
# Build contracts first (bridge depends on it), then bridge
RUN pnpm --filter @kanban/contracts build && \
pnpm --filter @kanban/bridge build
# ── Stage 2: Runtime ───────────────────────────────────────
FROM node:22-alpine AS runtime
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Copy workspace scaffolding
COPY --from=build /app/package.json ./
COPY --from=build /app/pnpm-lock.yaml ./
COPY --from=build /app/pnpm-workspace.yaml ./
# Copy built artifacts + package manifests
COPY --from=build /app/packages/contracts/package.json packages/contracts/
COPY --from=build /app/packages/contracts/dist packages/contracts/dist/
COPY --from=build /app/packages/bridge/package.json packages/bridge/
COPY --from=build /app/packages/bridge/dist packages/bridge/dist/
# Install production dependencies only
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3456
WORKDIR /app/packages/bridge
CMD ["node", "dist/index.js"]

View file

@ -91,9 +91,9 @@ export class HermesKanbanClient {
const fullArgs = [ const fullArgs = [
'kanban', 'kanban',
subcommand, subcommand,
...args,
'--board', board ?? this.config.defaultBoard, '--board', board ?? this.config.defaultBoard,
'--json', '--json',
...args,
]; ];
const env = { const env = {
@ -126,7 +126,7 @@ export class HermesKanbanClient {
// ─── Convenience methods (M2 will flesh these out) ──────── // ─── Convenience methods (M2 will flesh these out) ────────
async listBoards(): Promise<Board[]> { async listBoards(): Promise<Board[]> {
return this.exec<Board[]>('boards', []); return this.exec<Board[]>('boards', ['list']);
} }
async listTasks( async listTasks(

1464
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,3 @@
packages: packages:
- 'packages/*' - 'packages/*'
# Future: - 'apps/*' for BFF, frontend - 'frontend'