Compare commits

..

79 Commits

Author SHA1 Message Date
43db97aeaa fix(api): drop flask_cors from cm_api (CORS-A defense-in-depth)
api-server is internal-only after C5 (no host port in prod compose),
so the permissive 'CORS(app)' default never fires in normal operation.
Removing it eliminates a stale '*' Access-Control-Allow-Origin that
would become attack surface if a host port were ever accidentally
re-exposed.

Server-side fetches from web-view (legacy Flask) and web-next
(Next.js RSC) don't trigger CORS — that's a browser-only mechanism.

flask_cors stays in requirements.txt because cm_web_view.py still
imports it; both get removed in B4 when the legacy web-view retires.
2026-05-02 21:27:06 +08:00
3bfd35ef8d fix(web): PWA notch safe-area + skip autoFocus on touch devices
Adds viewportFit: 'cover' so the PWA can draw under the notch /
Dynamic Island when installed. Nav and Toast read env(safe-area-inset-*)
to keep their content out of the hardware cutouts (no-op on browsers
without a notch — env() resolves to 0).

Replaces autoFocus on the first field of CreateAccountDialog and
CreateUserDialog with a useEffect that only focuses on pointer devices
(matchMedia '(hover: hover) and (pointer: fine)'). Phones no longer
get the soft keyboard popping the instant a dialog opens.
2026-05-02 21:26:42 +08:00
eebbcb3db2 feat(web): success toast on confirmed create/delete
Adds a small top-centered <Toast> that fires only when the Server
Action returns { ok: true } (i.e., the DB write actually succeeded).
Auto-dismisses after 3s.

Wires both create dialogs (CreateAccountDialog, CreateUserDialog) with
an onSuccess callback that the table parent uses to push the toast,
and the delete confirm-flow does the same. Inline-edit success stays
quiet (no toast) — only add/delete trigger it, per the requested
scope.
2026-05-02 21:20:25 +08:00
e3ac94cada feat(web): manual create flow with input dialog for acc and user
api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
2026-05-02 21:19:24 +08:00
e507714dc5 feat(web): delete with confirm dialog + fix iOS auto-zoom on edit
Adds × delete button per row in both tables (desktop column +
mobile card header). Click → native <dialog> confirm modal with
Esc/backdrop-cancel, destructive red button, error inline.
Wires deleteAccount/deleteUser Server Actions calling the new
api-server routes; revalidatePath refreshes the list on success.

EditableCell input switches to text-base (16px) on phone (sm:text-[13px]
above 640px), preventing iOS Safari auto-zoom-on-focus that was
shifting the layout when the soft keyboard appeared.
2026-05-02 21:17:19 +08:00
dac1e10b5d feat(api): add /delete-acc-data and /delete-user-data routes 2026-05-02 21:15:21 +08:00
f13e3993e9 feat(web): reskin to refined SaaS aesthetic (per Dribbble reference)
Drop the brutalist hazard-tape vocabulary in favor of refined modern
SaaS: white cards on zinc-50, soft ring-1 zinc-200 borders (no hard
2px black), rounded-full pills, sans for chrome + mono for tabular
data, emerald replacing yellow as the saturated accent. Theme color
shifts to zinc-900 with an emerald dot on the icon.
2026-05-02 21:15:02 +08:00
3fe33772ce fix(web): editable cell wraps long values instead of overflowing
Long URLs in the link column would overflow on mobile because
truncate + inline-flex without min-w-0 expanded the cell beyond the
card width. Switch to flex+items-start, min-w-0 on the value span,
break-all so unbreakable strings wrap. Edit hint stays pinned right
with shrink-0.
2026-05-02 21:08:19 +08:00
b497e133bd feat(web): add PWA icons via Next.js ImageResponse (frontend-design) 2026-05-02 21:02:58 +08:00
afc94e613e feat(web): add PWA manifest config (theme_color matches layout viewport) 2026-05-02 21:01:18 +08:00
7a5c00d08a feat(web): add layout, nav, and error boundary (frontend-design) 2026-05-02 21:01:15 +08:00
c0749d1af0 feat(web): add Server Component entry pages for / and /users 2026-05-02 20:56:56 +08:00
7b97e593e5 feat(web): add data tables and editable-cell primitive (frontend-design) 2026-05-02 20:56:49 +08:00
0ebd35f964 feat(web): add 30s auto-refresh client component 2026-05-02 20:50:55 +08:00
b398faba0a feat(web): add updateAccount and updateUser Server Actions 2026-05-02 20:50:50 +08:00
3297c500a4 feat(web): add server-side api-server fetch helper 2026-05-02 20:50:45 +08:00
aa76131b23 feat(web): add TypeScript types for Acc and User 2026-05-02 20:50:38 +08:00
a9642a7121 Add implementation plan for B2+B3 (UI port + PWA) 2026-05-02 20:49:13 +08:00
2de545e854 Add design spec for B2+B3 (UI port + PWA) 2026-05-02 20:45:52 +08:00
21bb1f0dde fix(web): bump tailwind to ^4.1 (4.0.0 had a postcss/oxide serde mismatch) 2026-05-02 20:39:04 +08:00
ccb6c27c3d fix(web): pin typescript to 5.7.2 (5.7.0 was never published) 2026-05-02 20:37:33 +08:00
5cac356007 docs(spec): hide /api entirely — drop Route Handler section, document RSC+Server-Actions choice 2026-05-02 20:36:11 +08:00
ff99b1248a feat(web): hide /api entirely — RSC + Server Actions instead
The Route Handler proxy and hash mapping are gone. Browser never
hits a JSON endpoint: data reads happen in React Server Components
fetching api-server:3000 server-side; mutations (B2) will use
Next.js Server Actions. Zero public API surface to scrape or
enumerate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:34:31 +08:00
a04d7ecc50 docs(agents): document web/ Next.js project and cm-web-next dev URL 2026-05-02 20:33:00 +08:00
203ab5daef feat(scripts): include web-next in dev.sh and publish.sh 2026-05-02 20:32:47 +08:00
e78d8c8fa8 feat(envs): add CM_WEB_NEXT_HOST_PORT to all .env.example templates 2026-05-02 20:32:24 +08:00
17b38a7c35 feat(compose): add web-next service (side-by-side with web-view) 2026-05-02 20:32:20 +08:00
96fa650caf feat(docker): add multi-stage Dockerfile for cm-web-next 2026-05-02 20:31:53 +08:00
addc40e851 feat(web): hash-encoded API paths + catch-all Route Handler proxy 2026-05-02 20:31:38 +08:00
17e60db935 feat(web): add scaffold layout and page (frontend-design generated) 2026-05-02 20:31:16 +08:00
a556b4e3a0 feat(web): add .gitignore and .dockerignore 2026-05-02 20:25:43 +08:00
3b8973ba20 feat(web): bootstrap Next.js 15 project configs (no lockfile yet) 2026-05-02 20:25:36 +08:00
f0fbd01a79 feat(plan): wire hash-encoded API paths into B1 plan 2026-05-02 18:15:35 +08:00
31b092f231 feat(spec): hash-encode API paths at the cm-web-next public boundary 2026-05-02 18:14:40 +08:00
d60c5c97a9 Add implementation plan for B1 (Next.js scaffold) 2026-05-02 18:12:59 +08:00
bdcea8b9bc docs(spec): route web UI code through frontend-design skill 2026-05-02 18:09:58 +08:00
572b200603 Add design spec for B1 (Next.js scaffold + side-by-side deploy) 2026-05-02 18:07:47 +08:00
abc2f1b78d fix(scripts): dev.sh down --remove-orphans (cleans up prod-test leftovers) 2026-05-02 18:01:41 +08:00
e68e64065a refactor(scraper): make get_register_link and get_user_credit dump on failure 2026-05-02 17:55:12 +08:00
698e5bf22a refactor(scraper): convert input-value extractions to helper 2026-05-02 17:54:58 +08:00
b7bc534681 feat(scraper): add ScraperError + _dump_html + _find_input_value helpers 2026-05-02 17:54:21 +08:00
9ec0d2ade4 Add implementation plan for R3 (scraper resilience) 2026-05-02 17:52:58 +08:00
d4ab9f9c49 Add design spec for R3 (cm_bot.py scraper resilience) 2026-05-02 17:50:27 +08:00
f6505c1d1d docs(plan): fix Task 9 step 3 — rebuild with override, run with base 2026-05-02 17:43:05 +08:00
614718cd43 docs: add aaPanel hardening guide (C3/C4/C7 + dev vhost) 2026-05-02 17:39:35 +08:00
145f071ca4 docs(agents): drop stale 'hardcoded credentials' note (moved to env in 45303d0) 2026-05-02 17:38:35 +08:00
86b329340c chore(compose): drop api-server host port from base (internal only) 2026-05-02 17:38:26 +08:00
5c8483fa09 feat(compose): keep Flask dev server in dev override; expose api-server on localhost 2026-05-02 17:38:15 +08:00
1d4ecadfaa feat(docker): swap Flask dev server for gunicorn in api and web images 2026-05-02 17:38:01 +08:00
231ae69eef fix(hal): set_security_pin_api returns dict; cm_telegram now correct 2026-05-02 17:37:50 +08:00
d32e4ba58b feat(api): add create_app factory for gunicorn entrypoint 2026-05-02 17:37:13 +08:00
74d496b2bc build: add gunicorn 23.0.0 to requirements 2026-05-02 17:36:56 +08:00
6e2ec78418 Add implementation plan for prod hardening C1+C5+C6
9 bite-sized tasks: gunicorn dep, create_app() factory + tests, HAL
dict-return contract fix + bot_cli simplification, Dockerfile CMD
swaps, dev override (Flask dev server preserved), api-server host
port drop in base, AGENTS.md cleanup, aapanel-hardening.md (lifted
from spec appendix), integration verification deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:36:26 +08:00
e7ab6b1325 Add design spec for prod hardening (C1+C5+C6) and aaPanel guide
Bundles three independent prod-side improvements: replace Flask dev
server with gunicorn (C1), drop api-server's host port (C5), fix the
HAL set_security_pin_api bool/dict contract bug + clean up stale
AGENTS.md note (C6). Appendix is a hand-over guide for the aaPanel
operator (C3 basic auth, C4 rate-limit + scanner deflection, C7 host
firewall) including a vhost for heng.04080616.xyz routing to the dev
PC. Auth path locked to G3 (basic auth + iOS/Android keychain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:28:45 +08:00
8b8978831b Normalize rex/siong envs to .env.example + gitignore pattern
Untracks envs/rex/.env and envs/siong/.env (kept on disk so existing
deploys keep working) and adds matching .env.example templates so a
fresh clone has something to copy from. .gitignore widens from
envs/dev/.env to envs/*/.env to cover all three deployments.

Per-deployment secrets are no longer committed; rotation deemed
unnecessary because the repo is hosted on a private self-hosted Gitea
instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:12:56 +08:00
dff8be829c feat(seed): add 'done' acc rows and matching user records 2026-05-02 17:09:54 +08:00
6126430a3e docs(agents): document the local-as-dev tier and bot CLI 2026-05-02 17:03:33 +08:00
918243ee8b feat(envs): add dev .env.example and gitignore the filled-in copy 2026-05-02 17:03:08 +08:00
23c697d6fe feat(scripts): add bot_cli.sh wrapper, fix dev.sh help routing 2026-05-02 17:02:49 +08:00
48e5adbccd feat(scripts): add dev.sh lifecycle wrapper 2026-05-02 17:02:08 +08:00
57d4a8a68d feat(compose): add dev mysql service, init scripts, profile-gate bots 2026-05-02 17:01:48 +08:00
7011c6bada feat(bot_cli): implement interactive TUI menu and add subparser entry 2026-05-02 17:00:40 +08:00
f472a94916 feat(bot_cli): add monitor-once subcommand 2026-05-02 16:59:55 +08:00
e2eb32dacb feat(bot_cli): add credit and transfer subcommands 2026-05-02 16:59:32 +08:00
5844d7598a feat(bot_cli): add insert-user subcommand (Telegram /3 analog) 2026-05-02 16:59:10 +08:00
66d5feaea1 feat(bot_cli): add set-pin subcommand with local name resolution 2026-05-02 16:58:46 +08:00
f5d4a554d6 feat(bot_cli): add register subcommand (Telegram /1 analog) 2026-05-02 16:58:24 +08:00
c6e49c6240 feat(bot_cli): add module skeleton with parser sanity tests 2026-05-02 16:58:05 +08:00
c6742d1537 Add implementation plan for local-as-dev tier
13 bite-sized tasks: 7 TDD tasks for app/bot_cli.py (parser, six
subcommands, TUI), then mysql + init scripts, dev.sh + bot_cli.sh,
envs/dev/.env.example, AGENTS.md, and integration verification. Uses
unittest stdlib + unittest.mock; no new deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:57:17 +08:00
94ef5595ea Add design spec for local-as-dev tier (sub-project A)
Adds containerized MySQL to docker-compose.override.yml, gates
telegram/transfer bots behind a 'bots' profile, and introduces a local
Python bot CLI with a stdlib TUI menu that mirrors Telegram's /1, /2,
/3 plus operational subcommands. CLI runs from .venv against
127.0.0.1:3306 (mysql published to localhost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:49:12 +08:00
9db3980304 feat(scripts): add verify_debug.sh to test CM_DEBUG hotfix in dev 2026-05-02 16:28:35 +08:00
a2351c96f6 docs(agents): note CM_DEBUG default and intent 2026-05-02 16:23:17 +08:00
72ec2177db docs(env): document CM_DEBUG in .env.example 2026-05-02 16:23:10 +08:00
353af79e8b chore(compose): pass CM_DEBUG into api-server and web-view 2026-05-02 16:23:02 +08:00
c3f02b36b9 feat(api): make Werkzeug debug opt-in via CM_DEBUG 2026-05-02 16:22:23 +08:00
7cea119ad7 feat(web): make Werkzeug debug opt-in via CM_DEBUG 2026-05-02 16:21:51 +08:00
34f5398bff test: add CM_DEBUG helper parity test (failing) 2026-05-02 16:21:30 +08:00
40c3a76c13 Add implementation plan for debug-mode hotfix
Bite-sized TDD-style plan: failing helper test, two implementation
tasks (web then api), compose plumbing, doc updates, integration
verification. Uses unittest stdlib so no new deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:20:50 +08:00
97dbb79977 Add design spec for debug-mode hotfix (env-driven CM_DEBUG)
Documents the env-driven debug toggle that replaces the hardcoded
debug=True in cm_api.py and cm_web_view.py. Default off so the
Werkzeug debugger isn't reachable in rex/siong containers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:15:43 +08:00
72 changed files with 10784 additions and 129 deletions

View File

@ -1,3 +1,7 @@
# === Runtime ===
# Set to true ONLY in local dev. Werkzeug debugger = RCE if exposed.
CM_DEBUG=false
# === Deployment Identity === # === Deployment Identity ===
# Unique name prefix for containers and network (avoid conflicts on same host) # Unique name prefix for containers and network (avoid conflicts on same host)
CM_DEPLOY_NAME=rex-cm CM_DEPLOY_NAME=rex-cm

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ __pycache__
.DS_Store .DS_Store
*.html *.html
logs logs
envs/*/.env

View File

@ -7,7 +7,8 @@
- `cm_telegram.py` (Telegram bot + account monitor thread) - `cm_telegram.py` (Telegram bot + account monitor thread)
- `cm_transfer_credit.py` (scheduled transfer worker) - `cm_transfer_credit.py` (scheduled transfer worker)
- `db.py` (MySQL connection/retry logic) - `db.py` (MySQL connection/retry logic)
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`). - `web/` is the Next.js 15 app for the new web view (`cm-web-next` service). Tailwind v4, App Router, TypeScript. Side-by-side with the legacy Flask `cm_web_view.py` until B4 cuts over.
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`).
- `docker-compose.yml` uses registry images; `docker-compose.override.yml` swaps to local builds. - `docker-compose.yml` uses registry images; `docker-compose.override.yml` swaps to local builds.
- `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx. - `scripts/local_build.sh` starts local compose; `scripts/publish.sh` builds and pushes all images via buildx.
@ -30,38 +31,29 @@
TELEGRAM_ALERT_BOT_TOKEN=<optional> TELEGRAM_ALERT_BOT_TOKEN=<optional>
CM_TRANSFER_MAX_THREADS=1 CM_TRANSFER_MAX_THREADS=1
``` ```
4. Prepare MySQL schema (minimum required): 4. Prepare the local dev DB and stack:
```sql
CREATE DATABASE rex_cm CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE rex_cm;
CREATE TABLE acc (
username VARCHAR(64) PRIMARY KEY,
password VARCHAR(128) NOT NULL,
status VARCHAR(32) DEFAULT '',
link VARCHAR(512) DEFAULT ''
);
CREATE TABLE user (
f_username VARCHAR(64) PRIMARY KEY,
f_password VARCHAR(128) NOT NULL,
t_username VARCHAR(64) NOT NULL,
t_password VARCHAR(128) NOT NULL,
last_update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
5. Seed at least one `acc.username` matching prefix `13c...` (required by `CM_BOT_HAL.get_next_username()`), for example:
```sql
INSERT INTO acc (username, password, status, link) VALUES ('13c1000', 'seed', '', '');
```
6. Configure DB connection values:
- Default fallback is hardcoded in `app/db.py` (`DB_HOST=192.168.0.210`, etc.).
- For reliable reproduction, add `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` to service `environment:` in compose files (at minimum `api-server`, `telegram-bot`, `transfer-bot`).
7. Start services locally:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.override.yml up --build cp envs/dev/.env.example .env
# Edit .env if you want the bot CLI to actually call cm99.net
# (CM_AGENT_ID / CM_AGENT_PASSWORD / CM_SECURITY_PIN).
bash scripts/dev.sh up
``` ```
Or run `bash scripts/local_build.sh` (uses `sudo` by default). This brings up `mysql` (port `127.0.0.1:3306`), `api-server`, and
`web-view`. The schema and a 4-row seed are applied automatically
from `docker/mysql/init.d/`. Bots (`telegram-bot`, `transfer-bot`)
are gated behind a compose `bots` profile and do not start in dev.
## Dev Tier (Local Development)
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
- URLs: `http://localhost:8000/` (legacy Flask UI), `http://localhost:8010/` (new Next.js scaffold). Both run side-by-side until the B4 cutover retires the Flask version.
- Bot CLI: `bash scripts/bot_cli.sh` (drops into the TUI menu) or
`bash scripts/bot_cli.sh <subcommand>` (e.g., `register`, `set-pin <link>`,
`monitor-once --target 5`). The CLI runs in your local `.venv` and
connects to the dev mysql at `127.0.0.1:3306`.
- The auto-create monitor does NOT run in dev (it lives in `telegram-bot`,
which is gated by the `bots` profile). Use `bot_cli.sh monitor-once` to
exercise the same code path manually.
- Tests: `.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`: optional non-Docker local env. - `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`: optional non-Docker local env.
@ -71,7 +63,7 @@
## Verification Checklist ## Verification Checklist
- API responds: `curl http://localhost:3000/acc/` - API responds: `curl http://localhost:3000/acc/`
- Web UI loads: open `http://localhost:8001` - Web UI loads: open `http://localhost:8000` (dev) or `http://localhost:8001` (rex prod) / `http://localhost:8005` (siong prod).
- Service logs are clean: - Service logs are clean:
```bash ```bash
docker compose logs -f api-server web-view telegram-bot transfer-bot docker compose logs -f api-server web-view telegram-bot transfer-bot
@ -100,5 +92,5 @@
## Security & Configuration Tips ## Security & Configuration Tips
- Never commit real secrets in `.env`. - Never commit real secrets in `.env`.
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use. - `CM_DEBUG` defaults to `false` for both Flask services. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable).
- Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift. - Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.

View File

@ -9,21 +9,27 @@ Brief, copy/paste-ready steps to run the published images from `gitea.04080616.x
## Environment configs ## Environment configs
Pre-configured `.env` files for each deployment are in the `envs/` folder: Per-deployment templates live in `envs/<name>/.env.example` (committed). Each operator copies the example to a sibling `.env` (gitignored — never committed) and fills in the real secrets:
``` ```
envs/ envs/
├── rex/.env # Rex deployment (port 8001) ├── dev/.env.example # Local development tier — see "Local Development" below
└── siong/.env # Siong deployment (port 8005) ├── rex/.env.example # Rex deployment (port 8001)
└── siong/.env.example # Siong deployment (port 8005)
``` ```
For local development, copy the desired env to the project root: For Portainer-hosted deployments (rex/siong):
```bash ```bash
cp envs/rex/.env .env cp envs/rex/.env.example envs/rex/.env
# or # Fill in DB_PASSWORD, CM_AGENT_*, CM_SECURITY_PIN, TELEGRAM_BOT_TOKEN, etc.
cp envs/siong/.env .env # Then load the variables into the Portainer stack environment.
``` ```
For Portainer, load the env vars from the appropriate file into the stack environment variables. For local development, see the dev tier flow:
```bash
cp envs/dev/.env.example .env
bash scripts/dev.sh up
```
## Key variables ## Key variables
| Variable | Description | | Variable | Description |

167
app/bot_cli.py Normal file
View File

@ -0,0 +1,167 @@
"""Local dev CLI for the CM bot. Mirrors Telegram /1, /2, /3 plus
operational commands. No-arg invocation drops into a stdlib TUI menu.
"""
import argparse
import sys
from .cm_bot_hal import CM_BOT_HAL
# Map TUI shortcuts to argparse subcommand names so the REPL reuses the
# same dispatch table as one-shot invocations.
_TUI_ALIASES = {"1": "register", "2": "set-pin", "3": "insert-user"}
def cmd_interactive(_args):
"""Telegram-style menu in a TTY loop. stdlib only."""
print("CM Bot CLI — interactive (type 'q' to quit, '?' for menu)")
while True:
print()
print(" 1 Register / get next account")
print(" 2 <whatsapp_link> Set security PIN")
print(" 3 <f_username> <t_username> Insert into user table")
print(" credit <username> <password> Read account credit")
print(" transfer <fu> <fp> <tu> <tp> One-shot credit transfer")
print(" monitor [N] Run monitor once (default 20)")
print(" q Quit")
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if not line:
continue
if line in ("q", "quit", "exit"):
return
if line in ("?", "help", "menu"):
continue
argv = line.split()
argv[0] = _TUI_ALIASES.get(argv[0], argv[0])
try:
args = build_parser().parse_args(argv)
args.func(args)
except SystemExit:
continue
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
def _print_user(user: dict) -> None:
print(f"Username: {user['username']}")
print(f"Password: {user['password']}")
print(f"Link: {user['link']}")
def cmd_register(_args):
bot = CM_BOT_HAL()
_print_user(bot.get_user_api())
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
result = bot.set_security_pin_api(args.link)
print(f"OK: f_username={result['f_username']} t_username={result['t_username']}")
def cmd_insert_user(args):
bot = CM_BOT_HAL()
f_password = bot.get_user_pass_from_acc(args.f_username)
if not f_password:
print(f"ERROR: no password for {args.f_username}", file=sys.stderr)
sys.exit(2)
success = bot.insert_user_to_table_user({
"f_username": args.f_username,
"f_password": f_password,
"t_username": args.t_username,
"t_password": bot.security_pin,
})
if not success:
print("ERROR: insert failed", file=sys.stderr)
sys.exit(1)
print(f"OK: inserted {args.f_username}{args.t_username}")
def cmd_credit(args):
bot = CM_BOT_HAL()
print(f"Credit: {bot.get_user_credit(args.username, args.password)}")
def cmd_transfer(args):
bot = CM_BOT_HAL()
print(bot.transfer_credit_api(
args.f_username, args.f_password,
args.t_username, args.t_password,
))
def cmd_monitor_once(args):
bot = CM_BOT_HAL()
available = bot.get_all_available_acc()
print(f"Available accounts: {len(available)} (target: {args.target})")
if len(available) >= args.target:
print("Already at target; nothing to do.")
return
for _ in range(len(available), args.target):
try:
user = bot.create_new_acc()
print(f"Created: {user['username']}")
except Exception as exc:
print(f"ERROR creating account: {exc}", file=sys.stderr)
sys.exit(1)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="bot_cli",
description="CM Bot dev CLI (mirrors Telegram triggers).",
)
sub = p.add_subparsers(dest="command")
sp = sub.add_parser("register", aliases=["get-acc"], help="Get next available account (Telegram /1).")
sp.set_defaults(func=cmd_register)
sp = sub.add_parser("set-pin", help="Set security PIN from a WhatsApp link (Telegram /2).")
sp.add_argument("link")
sp.set_defaults(func=cmd_set_pin)
sp = sub.add_parser("insert-user", help="Insert into user table (Telegram /3).")
sp.add_argument("f_username")
sp.add_argument("t_username")
sp.set_defaults(func=cmd_insert_user)
sp = sub.add_parser("credit", help="Read account credit balance.")
sp.add_argument("username")
sp.add_argument("password")
sp.set_defaults(func=cmd_credit)
sp = sub.add_parser("transfer", help="One-shot credit transfer.")
sp.add_argument("f_username")
sp.add_argument("f_password")
sp.add_argument("t_username")
sp.add_argument("t_password")
sp.set_defaults(func=cmd_transfer)
sp = sub.add_parser("monitor-once", aliases=["monitor"], help="One iteration of the auto-create monitor.")
sp.add_argument("--target", type=int, default=20)
sp.set_defaults(func=cmd_monitor_once)
sp = sub.add_parser("interactive", help="Drop into the TUI menu.")
sp.set_defaults(func=cmd_interactive)
return p
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command is None:
return cmd_interactive(args) or 0
return args.func(args) or 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,14 +1,29 @@
import os
import threading import threading
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from flask_cors import CORS
from .db import DB from .db import DB
def _debug_enabled() -> bool:
"""Return True iff CM_DEBUG env var is set to a truthy value.
Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed).
Anything else, including unset, is False. Default-off so the
Werkzeug debugger is never reachable in production containers.
"""
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
class CM_API: class CM_API:
def __init__(self): def __init__(self):
self.app = Flask(__name__) self.app = Flask(__name__)
CORS(self.app) # No CORS middleware: api-server is internal-only (no host port
# in prod compose, per C5). Browsers can't reach it directly,
# and server-side fetches from web-view / web-next don't trigger
# CORS. Removing flask_cors removes a permissive '*' origin
# default that becomes an attack surface if a host port is ever
# accidentally re-exposed.
self._register_routes() self._register_routes()
def _get_database_connection(self): def _get_database_connection(self):
@ -44,6 +59,14 @@ class CM_API:
# Update routes # Update routes
self.app.route('/update-acc-data', methods=['POST'])(self.update_acc_data) self.app.route('/update-acc-data', methods=['POST'])(self.update_acc_data)
self.app.route('/update-user-data', methods=['POST'])(self.update_user_data) self.app.route('/update-user-data', methods=['POST'])(self.update_user_data)
# Delete routes
self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data)
self.app.route('/delete-user-data', methods=['POST'])(self.delete_user_data)
# Create routes (manual operator input)
self.app.route('/create-acc-data', methods=['POST'])(self.create_acc_data)
self.app.route('/create-user-data', methods=['POST'])(self.create_user_data)
def _check_database_available(self): def _check_database_available(self):
db = self._get_database_connection() db = self._get_database_connection()
@ -157,14 +180,126 @@ class CM_API:
finally: finally:
self._close_database_connection(db) self._close_database_connection(db)
def run(self, port=3000, debug=True): def delete_acc_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
username = data.get('username')
if not username:
return jsonify({"error": "Username is required"}), 400
result = db.execute(
"DELETE FROM acc WHERE username = %s",
[username]
)
if result:
return jsonify({"deleted": username})
return jsonify({"error": "Failed to delete account"}), 500
except Exception as error:
return self._handle_error(error, "Error deleting account"), 500
finally:
self._close_database_connection(db)
def delete_user_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
f_username = data.get('f_username')
if not f_username:
return jsonify({"error": "f_username is required"}), 400
result = db.execute(
"DELETE FROM user WHERE f_username = %s",
[f_username]
)
if result:
return jsonify({"deleted": f_username})
return jsonify({"error": "Failed to delete user"}), 500
except Exception as error:
return self._handle_error(error, "Error deleting user"), 500
finally:
self._close_database_connection(db)
def create_acc_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
status = data.get('status') or ''
link = data.get('link') or ''
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
result = db.execute(
"INSERT INTO acc (username, password, status, link) VALUES (%s, %s, %s, %s)",
[username, password, status, link]
)
if result:
return jsonify({"created": username})
return jsonify({"error": "Failed to create account"}), 500
except Exception as error:
return self._handle_error(error, "Error creating account"), 500
finally:
self._close_database_connection(db)
def create_user_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
f_username = (data.get('f_username') or '').strip()
f_password = data.get('f_password') or ''
t_username = (data.get('t_username') or '').strip()
t_password = data.get('t_password') or ''
if not f_username or not f_password or not t_username or not t_password:
return jsonify({"error": "All fields are required"}), 400
result = db.execute(
"INSERT INTO user (f_username, f_password, t_username, t_password) VALUES (%s, %s, %s, %s)",
[f_username, f_password, t_username, t_password]
)
if result:
return jsonify({"created": f_username})
return jsonify({"error": "Failed to create user"}), 500
except Exception as error:
return self._handle_error(error, "Error creating user"), 500
finally:
self._close_database_connection(db)
def run(self, port=3000, debug=None):
if debug is None:
debug = _debug_enabled()
# Test database connection before starting server # Test database connection before starting server
test_db = self._get_database_connection() test_db = self._get_database_connection()
if test_db is None: if test_db is None:
print("Cannot start server: Database not available") print("Cannot start server: Database not available")
exit(1) exit(1)
self._close_database_connection(test_db) self._close_database_connection(test_db)
print(f'CM Bot DB API Listening at Port : {port}') print(f'CM Bot DB API Listening at Port : {port}')
self.app.run(host='0.0.0.0', port=port, debug=debug) self.app.run(host='0.0.0.0', port=port, debug=debug)
@ -186,6 +321,17 @@ class CM_API:
return thread return thread
def create_app():
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
Returns the Flask app object so gunicorn can serve it. The
surrounding CM_API class still owns route registration and DB
connection management this just hands gunicorn the underlying
Flask instance.
"""
return CM_API().app
if __name__ == '__main__': if __name__ == '__main__':
api = CM_API() api = CM_API()
api.run(port = 3000) api.run(port = 3000)

View File

@ -3,8 +3,15 @@ import requests, re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import os import os
# with open('security_response.html', 'wb') as f:
# f.write(response.content) class ScraperError(Exception):
"""A cm99.net response did not contain the field we expected.
The raw response is saved to logs/scraper-failures/ before this is
raised; the message identifies which method failed and what was
being looked for.
"""
class CM_BOT: class CM_BOT:
def __init__(self): def __init__(self):
@ -202,6 +209,41 @@ class CM_BOT:
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36' 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
} }
def _dump_html(self, context: str, content) -> str:
"""Save a failing cm99.net response to logs/scraper-failures/.
Returns the path written to so callers can include it in error
messages.
"""
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir = os.path.join("logs", "scraper-failures")
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, f"{context}-{ts}.html")
if isinstance(content, (bytes, bytearray)):
data = bytes(content)
else:
data = str(content).encode("utf-8", "replace")
with open(path, "wb") as f:
f.write(data)
print(f"[scraper-failure] dumped {context} response to {path}")
return path
def _find_input_value(self, soup, ident: str, *, context: str, raw, by: str = "name") -> str:
"""Extract <input {by}=IDENT value=...>'s value or raise ScraperError.
`by` selects between matching <input name=...> (default) and
<input id=...>. Saves the raw response to logs/scraper-failures/
before raising so the operator can postmortem.
"""
el = soup.find("input", {by: ident})
if el is None or "value" not in el.attrs:
path = self._dump_html(context, raw)
raise ScraperError(
f"{context}: input[{by}={ident!r}] missing or has no value attribute "
f"(response saved to {path})"
)
return el["value"]
def get_register_data(self, token: str, username: str, password: str): def get_register_data(self, token: str, username: str, password: str):
return { return {
'struts.token.name': 'token', 'struts.token.name': 'token',
@ -344,11 +386,15 @@ class CM_BOT:
def get_register_form_token(self): def get_register_form_token(self):
try: try:
response = self.session.post( response = self.session.post(
f'{self.base_url}/cm/loadUserAccount', f'{self.base_url}/cm/loadUserAccount',
headers=self.get_register_form_headers headers=self.get_register_form_headers
) )
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value'] return self._find_input_value(
soup, "token",
context="register_form_token",
raw=response.content,
)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"Error getting register form: {e}") print(f"Error getting register form: {e}")
return None return None
@ -357,7 +403,11 @@ class CM_BOT:
def get_security_pin_form_token(self): def get_security_pin_form_token(self):
response = self.session.get(f'{self.base_url}/cm/setSecurityPin') response = self.session.get(f'{self.base_url}/cm/setSecurityPin')
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value'] return self._find_input_value(
soup, "token",
context="security_pin_form_token",
raw=response.content,
)
def register_user(self, user_id, user_password): def register_user(self, user_id, user_password):
try: try:
@ -402,8 +452,21 @@ class CM_BOT:
def get_register_link(self): def get_register_link(self):
response = self.session.get(f"{self.base_url}/cm/showQrCode") response = self.session.get(f"{self.base_url}/cm/showQrCode")
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
soup = soup.find('form', {'id': 'qrCodeForm'}) form = soup.find('form', {'id': 'qrCodeForm'})
return soup.find('a')['href'] if form is None:
path = self._dump_html("register_link_form", response.content)
raise ScraperError(
f"register_link: form#qrCodeForm not found "
f"(response saved to {path})"
)
anchor = form.find('a')
if anchor is None or 'href' not in anchor.attrs:
path = self._dump_html("register_link_anchor", response.content)
raise ScraperError(
f"register_link: <a href> inside form#qrCodeForm not found "
f"(response saved to {path})"
)
return anchor['href']
def get_generate_username(self, max_username_index: int): def get_generate_username(self, max_username_index: int):
max_username_index += 1 max_username_index += 1
@ -432,17 +495,29 @@ class CM_BOT:
headers=self.transfer_search_headers headers=self.transfer_search_headers
) )
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
name = soup.find('input', {'id': "name"})['value'] name = self._find_input_value(
token = soup.find('input', {'name': "token"})['value'] soup, "name",
toUserId = soup.find('input', {'id': "toUserId"})['value'] context="transfer_search_name",
raw=response.content,
by="id",
)
token = self._find_input_value(
soup, "token",
context="transfer_search_token",
raw=response.content,
)
toUserId = self._find_input_value(
soup, "toUserId",
context="transfer_search_toUserId",
raw=response.content,
by="id",
)
transfer_data = self.get_transfer_data(token, t_username, name, toUserId, amount, t_password) transfer_data = self.get_transfer_data(token, t_username, name, toUserId, amount, t_password)
response = self.session.post( response = self.session.post(
f'{self.base_url}/cm/saveTransfer', f'{self.base_url}/cm/saveTransfer',
data=transfer_data, data=transfer_data,
headers=self.transfer_credit_headers headers=self.transfer_credit_headers
) )
# with open('transfer_credit.html', 'wb') as f:
# f.write(response.content)
return True if re.search(r'Successfully saved the record\.', response.text) else False return True if re.search(r'Successfully saved the record\.', response.text) else False
def get_user_credit(self): def get_user_credit(self):
@ -453,17 +528,19 @@ class CM_BOT:
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
try: try:
return round(float(soup.find('table', {'class': 'generalContent'}).find(text=re.compile('Credit Available')).parent.parent.find_all('td')[2].text.replace(",","")), 2) return round(float(soup.find('table', {'class': 'generalContent'}).find(text=re.compile('Credit Available')).parent.parent.find_all('td')[2].text.replace(",","")), 2)
except: except Exception as exc:
print(f"Error getting credit.") self._dump_html("get_user_credit", response.content)
now = datetime.datetime.now().strftime('%Y%m%d_%H%M') print(f"Error getting credit: {exc}")
# with open(f'credit-{now}.html', 'wb') as f: return 0
# f.write(response.content)
return 0
def get_transfer_token(self): def get_transfer_token(self):
response = self.session.get(f'{self.base_url}/cm/transfer') response = self.session.get(f'{self.base_url}/cm/transfer')
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value'] return self._find_input_value(
soup, "token",
context="transfer_token",
raw=response.content,
)
def logout(self): def logout(self):
"""Logout from the system.""" """Logout from the system."""

View File

@ -177,7 +177,7 @@ class CM_BOT_HAL:
) )
if result == False: if result == False:
raise Exception('Failed to insert user to table user') raise Exception('Failed to insert user to table user')
return result return {"f_username": f_username, "t_username": t_username}
def get_user_credit(self, f_username: str, f_password: str): def get_user_credit(self, f_username: str, f_password: str):
cm_bot = CM_BOT() cm_bot = CM_BOT()

View File

@ -13,6 +13,16 @@ PREFIX_PATTERN = os.getenv('CM_PREFIX_PATTERN', '')
print("API: ", API_BASE_URL) print("API: ", API_BASE_URL)
print("Prefix pattern: ", PREFIX_PATTERN) print("Prefix pattern: ", PREFIX_PATTERN)
def _debug_enabled() -> bool:
"""Return True iff CM_DEBUG env var is set to a truthy value.
Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed).
Anything else, including unset, is False. Default-off so the
Werkzeug debugger is never reachable in production containers.
"""
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
# Beautiful HTML template with modern styling # Beautiful HTML template with modern styling
HTML_TEMPLATE = """ HTML_TEMPLATE = """
<!DOCTYPE html> <!DOCTYPE html>
@ -745,4 +755,4 @@ if __name__ == '__main__':
print("Starting CM Web View...") print("Starting CM Web View...")
print("Web interface will be available at: http://localhost:8000") print("Web interface will be available at: http://localhost:8000")
print("Make sure the API server is running on port 3000") print("Make sure the API server is running on port 3000")
app.run(host='0.0.0.0', port=8000, debug=True) app.run(host='0.0.0.0', port=8000, debug=_debug_enabled())

View File

@ -4,18 +4,32 @@ services:
context: . context: .
dockerfile: docker/telegram/Dockerfile dockerfile: docker/telegram/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-telegram:${DOCKER_IMAGE_TAG:-dev}" image: "${CM_IMAGE_PREFIX:-local}/cm-telegram:${DOCKER_IMAGE_TAG:-dev}"
profiles: ["bots"]
api-server: api-server:
build: build:
context: . context: .
dockerfile: docker/api/Dockerfile dockerfile: docker/api/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}" image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_api"]
ports:
- "127.0.0.1:3000:3000"
depends_on:
mysql:
condition: service_healthy
web-view: web-view:
build: build:
context: . context: .
dockerfile: docker/web/Dockerfile dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}" image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_web_view"]
web-next:
build:
context: .
dockerfile: docker/web-next/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
transfer-bot: transfer-bot:
build: build:
@ -27,3 +41,32 @@ services:
CM_TRANSFER_MAX_THREADS: "1" CM_TRANSFER_MAX_THREADS: "1"
mem_limit: 2g mem_limit: 2g
cpus: 2 cpus: 2
profiles: ["bots"]
mysql:
image: mysql:8.0
container_name: ${CM_DEPLOY_NAME:-cm}-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-devroot}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.d:/docker-entrypoint-initdb.d:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-devroot}"]
interval: 5s
timeout: 3s
retries: 12
networks:
- bot-network
volumes:
mysql-data:
name: ${CM_DEPLOY_NAME:-cm}-mysql-data

View File

@ -35,10 +35,9 @@ services:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-api:${DOCKER_IMAGE_TAG:-latest}" image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-api:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-api-server container_name: ${CM_DEPLOY_NAME:-cm}-api-server
restart: unless-stopped restart: unless-stopped
ports:
- "3000"
environment: environment:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
DB_HOST: ${DB_HOST} DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER} DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
@ -62,6 +61,7 @@ services:
- "${CM_WEB_HOST_PORT:-8001}:8000" - "${CM_WEB_HOST_PORT:-8001}:8000"
environment: environment:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
API_BASE_URL: http://api-server:3000 API_BASE_URL: http://api-server:3000
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN} CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
volumes: volumes:
@ -72,6 +72,25 @@ services:
depends_on: depends_on:
- api-server - api-server
# Next.js Web View (side-by-side with web-view during B-cycle migration).
web-next:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
restart: unless-stopped
ports:
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
API_BASE_URL: http://api-server:3000
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- bot-network
depends_on:
- api-server
transfer-bot: transfer-bot:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-transfer:${DOCKER_IMAGE_TAG:-latest}" image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-transfer:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-transfer-bot container_name: ${CM_DEPLOY_NAME:-cm}-transfer-bot

View File

@ -16,5 +16,5 @@ ENV PYTHONUNBUFFERED=1
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000
# Run the API server # Run the API server with gunicorn (Flask dev server is for the dev override).
CMD ["python", "-m", "app.cm_api"] CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:3000", "app.cm_api:create_app()"]

View File

@ -0,0 +1,17 @@
-- Schema for the CM bot dev DB. Mounted at
-- /docker-entrypoint-initdb.d/01-schema.sql in the mysql:8.0 container;
-- runs once on first volume initialization.
CREATE TABLE IF NOT EXISTS acc (
username VARCHAR(64) PRIMARY KEY,
password VARCHAR(128) NOT NULL,
status VARCHAR(32) DEFAULT '',
link VARCHAR(512) DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user (
f_username VARCHAR(64) PRIMARY KEY,
f_password VARCHAR(128) NOT NULL,
t_username VARCHAR(64) NOT NULL,
t_password VARCHAR(128) NOT NULL,
last_update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,25 @@
-- Dev-only seed. Passwords are placeholder strings — never real cm99.net
-- credentials. The `acc` rows match CM_PREFIX_PATTERN=13c so
-- get_next_username has something to anchor on.
-- Available pool: status='' is the "ready, not yet assigned" state read by
-- get_all_available_acc / get_user_data_from_acc.
INSERT INTO acc (username, password, status, link) VALUES
('13c1000', 'seedpass', '', ''),
('13c1001', 'seedpass', '', ''),
('13c1002', 'seedpass', '', ''),
('13c1003', 'seedpass', '', '');
-- Two completed pairs: agent accounts already linked to players. status='done'
-- is the terminal state set by update_user_status_to_done after the security
-- PIN is configured.
INSERT INTO acc (username, password, status, link) VALUES
('13c1010', 'seedpass-completed', 'done', 'https://chat.whatsapp.com/SEED-EXAMPLE-1'),
('13c1011', 'seedpass-completed', 'done', 'https://chat.whatsapp.com/SEED-EXAMPLE-2');
-- Each `user` row matches one of the 'done' acc rows above. f_username/f_password
-- mirror the agent (acc) credentials; t_username/t_password are the player side
-- (t_password is the security pin set on cm99.net).
INSERT INTO user (f_username, f_password, t_username, t_password) VALUES
('13c1010', 'seedpass-completed', 'player_one_seed', '000000'),
('13c1011', 'seedpass-completed', 'player_two_seed', '000000');

View File

@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY web/package.json web/package-lock.json* ./
# `npm install` (not `npm ci`) because the host doesn't generate the
# lockfile during dev. Docker resolves it on first build; subsequent
# builds reuse the cached layer until package.json changes.
RUN npm install --no-audit --no-fund
# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/ ./
RUN npm run build
# --- runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@ -16,5 +16,5 @@ ENV PYTHONUNBUFFERED=1
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Run the web view # Run the web view with gunicorn (Flask dev server is for the dev override).
CMD ["python", "-m", "app.cm_web_view"] CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"]

168
docs/aapanel-hardening.md Normal file
View File

@ -0,0 +1,168 @@
# aaPanel Hardening Guide (Operator)
This is the hand-over guide for the C3 (auth), C4 (rate-limit + scanner deflection), and C7 (host firewall) slices of the prod hardening cycle. None of this is implemented in the repo — it lives in your aaPanel configuration and on your Flask host(s).
Companion spec: [superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md](superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md).
## Threat model
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable web-view ports on the Flask hosts (8001 rex, 8005 siong, 8000 dev). A scanner on the public internet → aaPanel → Flask. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy to Flask. With them, aaPanel returns 444 immediately and Flask never sees the request.
## C3 — Basic auth on the rex/siong/dev vhosts
Goal: the web-view UI requires a password. Anyone hitting `https://<domain>/` with no creds gets 401.
Generate an htpasswd file (one per deployment is cleaner):
```bash
# On the aaPanel host, as root:
htpasswd -c /www/server/panel/data/htpasswd-rex rex-operator
htpasswd -c /www/server/panel/data/htpasswd-siong siong-operator
htpasswd -c /www/server/panel/data/htpasswd-dev dev-operator
chmod 640 /www/server/panel/data/htpasswd-*
chown www:www /www/server/panel/data/htpasswd-*
```
Add to the rex vhost's `server { ... }` block (aaPanel: site → settings → "Configuration File"):
```nginx
auth_basic "rex restricted";
auth_basic_user_file /www/server/panel/data/htpasswd-rex;
```
Same shape for siong (`htpasswd-siong`) and dev (`htpasswd-dev`). Use a different password per deployment — reusing the same one means a leaked dev credential exposes prod. Reload nginx (aaPanel does this automatically on save).
### Phone UX note
Basic auth + iOS/Android keychain + Face ID / Touch ID flow: on first login, save the password into the OS keychain when prompted ("Save password to iCloud Keychain" on iOS, "Save to Google Password Manager" on Android). Subsequent visits trigger Face ID / fingerprint to autofill the basic-auth dialog. Caveats:
- **Safari (iOS):** integration is reliable. Face ID prompts almost every visit unless you tick "Remember me on this device" in Safari's password autofill settings.
- **Chrome (Android):** Google Password Manager autofills basic-auth in newer Chrome versions; biometric prompt appears.
- **In-app browsers (Telegram, WhatsApp link previews):** often *don't* autofill basic-auth and force you to type. If this matters, share `https://...` URLs and ask people to open in their default browser.
If autofill behavior is choppy, the upgrade path is Authelia + WebAuthn passkeys — its own future cycle, not in this one.
## C4 — Rate limit + scanner deflection
### Scanner deflection (444 on known probe paths)
In each vhost's `server { ... }`:
```nginx
# Deflect generic web vulnerability scanners. Return 444 (no response,
# closes connection) instead of letting them reach Flask.
location ~* "^/(\.env|\.env\..*|\.git/.*|\.aws/.*|\.dockerenv|\.htpasswd|\.npmrc|.+\.php|i\.php|test\.php|php\.php|wp-(login|admin|content)/)" {
access_log off;
return 444;
}
# Robots: tell well-behaved crawlers to leave us alone.
location = /robots.txt {
add_header Content-Type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
```
### Rate limit (per source IP)
In the `http { ... }` block (one level above `server`; in aaPanel typically lives in the global nginx config or in a snippet):
```nginx
# 10MB shared zone, 30 requests/sec per source IP.
limit_req_zone $binary_remote_addr zone=cm_general:10m rate=30r/s;
```
Then inside each vhost's `server { ... }`:
```nginx
# Allow short bursts (60 reqs above rate) before throttling.
limit_req zone=cm_general burst=60 nodelay;
limit_req_status 429;
```
30 r/s × per-IP is generous for legitimate UI traffic and tight enough to slow a scanner down to nuisance levels.
## Dev vhost — `heng.04080616.xyz` → dev PC
The dev tier (sub-project A) runs on a dev PC: `bash scripts/dev.sh up` → web-view on `0.0.0.0:8000`. Routing aaPanel to it adds public reach (with auth) so you can hand someone a URL to test against without giving them VPN.
aaPanel vhost for `heng.04080616.xyz` (in addition to the C3/C4 blocks above):
```nginx
location / {
proxy_pass http://<dev-pc-lan-ip>:8000;
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_read_timeout 60s;
}
```
Replace `<dev-pc-lan-ip>` with the dev PC's address on your LAN.
⚠️ **Important: turn `CM_DEBUG` OFF in the dev `.env` before letting aaPanel proxy to dev.** The dev tier defaults to `CM_DEBUG=true` (per `envs/dev/.env.example`), which enables Werkzeug's debugger. With aaPanel proxying publicly, basic auth is the only thing standing between the internet and an interactive Python REPL on the dev PC. The right pattern is:
- `CM_DEBUG=true` only when iterating *fully locally* (no aaPanel proxy active, no port forward).
- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`.
If you'd rather not flip the flag manually, set `CM_DEBUG=false` permanently in your dev `.env` and run `bash scripts/bot_cli.sh` for the workflows you used to want the debugger for. The Flask in-browser tracebacks aren't worth the RCE surface.
## C7 — Host firewall on each Flask host
Restrict the LAN-reachable web-view ports to only aaPanel's IP. Without this, anyone else on the LAN can hit Flask directly and bypass everything in C3 and C4. Apply on each host that runs a Flask stack: rex, siong, *and* the dev PC.
Replace `<aapanel-host-ip>` with the address of your aaPanel box.
On rex/siong hosts (ports 8001 / 8005):
```bash
sudo ufw allow from <aapanel-host-ip> to any port 8001 proto tcp comment 'rex web-view ← aaPanel only'
sudo ufw allow from <aapanel-host-ip> to any port 8005 proto tcp comment 'siong web-view ← aaPanel only'
sudo ufw deny 8001/tcp
sudo ufw deny 8005/tcp
sudo ufw reload
sudo ufw status numbered
```
On the dev PC (port 8000 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`):
```bash
sudo ufw allow from <aapanel-host-ip> to any port 8000 proto tcp comment 'dev web-view ← aaPanel only'
sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost'
sudo ufw deny 8000/tcp
sudo ufw reload
```
The localhost rule on the dev PC is so you can still load `http://localhost:8000` directly while iterating, without going through aaPanel.
Verify from a third machine on the LAN:
```bash
nmap -p 8000,8001,8005 <flask-host-ip>
# All three ports should show 'filtered' from anywhere except the aaPanel host
# (and except localhost on the dev PC).
```
If you don't run ufw and prefer iptables directly, the equivalent rules are:
```bash
iptables -A INPUT -p tcp --dport 8001 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8005 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 8001 -j DROP
iptables -A INPUT -p tcp --dport 8005 -j DROP
iptables -A INPUT -p tcp --dport 8000 -j DROP
```
(Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.)
## Verification (after all blocks applied)
1. Hit any UI without creds: `curl -i https://<rex-domain>/``401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`.
2. With creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/``200 OK` with JSON.
3. Scanner path: `curl -i https://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request.
4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; done | sort | uniq -c` → mix of `200`/`401` (depending on auth state) up to the burst, then `429`s.
5. From a non-aaPanel host on the LAN: `nmap -p 8000,8001,8005 <flask-host-ip>` → all three ports `filtered` (localhost on dev PC still allowed).
6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs | grep "Debugger PIN"` should return nothing once `CM_DEBUG` is off. Then `curl -i -u dev-operator:<password> https://heng.04080616.xyz/api/acc/` returns the seed accounts.

View File

@ -0,0 +1,937 @@
# B1: Next.js Scaffold Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up a `web/` Next.js 15 project, a `docker/web-next/Dockerfile`, and a `web-next` compose service exposed on `${CM_WEB_NEXT_HOST_PORT:-8010}` that serves a `frontend-design`-generated placeholder page and a catch-all Route Handler proxy to `api-server:3000`.
**Architecture:** Hand-roll the Next.js project files instead of using `npx create-next-app` for reproducibility. Tailwind v4 (CSS-first config — no `tailwind.config.ts`). Catch-all `web/app/api/[...path]/route.ts` forwards GET/POST to `api-server:3000` preserving the trailing slash. Multi-stage `node:22-alpine` Dockerfile with `output: "standalone"`. Side-by-side with the existing `cm-web` Flask service — both run; B4 retires Flask later.
**Tech Stack:** Next.js 15.x stable, React 19.x, TypeScript 5.x, Tailwind CSS v4 (`@tailwindcss/postcss`), Node 22 LTS, npm. No new dev tools. UI code (`layout.tsx`, `page.tsx`) generated by the `frontend-design` skill per the spec.
**Spec:** [docs/superpowers/specs/2026-05-02-b1-nextjs-scaffold-design.md](../specs/2026-05-02-b1-nextjs-scaffold-design.md)
---
## File Map
| File | Operation | Purpose |
|---|---|---|
| `web/package.json` | Create | Next.js 15 + React 19 + Tailwind v4 deps; npm scripts. |
| `web/package-lock.json` | Create | Generated by `npm install`. Committed for reproducible Docker builds. |
| `web/tsconfig.json` | Create | TypeScript config matching Next.js 15 defaults. |
| `web/next.config.ts` | Create | `output: "standalone"`, `trailingSlash: true`. |
| `web/postcss.config.mjs` | Create | Tailwind v4 PostCSS plugin. |
| `web/.gitignore` | Create | `.next/`, `node_modules/`, build/test outputs. |
| `web/.dockerignore` | Create | Excludes `node_modules/`, `.next/`, `.git/`. |
| `web/next-env.d.ts` | Create | Auto-generated reference; committed verbatim. |
| `web/app/layout.tsx` | Create (via frontend-design) | Root layout. |
| `web/app/page.tsx` | Create (via frontend-design) | Scaffold-confirmation placeholder. |
| `web/app/globals.css` | Create | `@import "tailwindcss";` |
| `web/app/api/[...path]/route.ts` | Create | Catch-all GET/POST proxy that maps the public hash to the upstream path and forwards to `api-server:3000`. |
| `web/lib/api-paths.ts` | Create | Hash → upstream-name mapping (single source of truth for both the Route Handler and future client code). |
| `docker/web-next/Dockerfile` | Create | Multi-stage Node 22 alpine, standalone output. |
| `docker-compose.yml` | Modify | Add `web-next` service. |
| `docker-compose.override.yml` | Modify | Add `web-next` build directive. |
| `envs/dev/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8010` |
| `envs/rex/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8011` |
| `envs/siong/.env.example` | Modify | `CM_WEB_NEXT_HOST_PORT=8012` |
| `scripts/dev.sh` | Modify | Include `web-next` in `up`/`logs`/`reset-db`. |
| `scripts/publish.sh` | Modify | Append `web-next` to `SERVICES`. |
| `AGENTS.md` | Modify | Mention `web/` and the `cm-web-next` service. |
No file removals. Nothing in `app/` is touched.
---
## Task 1: Bootstrap `web/` package + configs
**Files:**
- Create: `web/package.json`
- Create: `web/tsconfig.json`
- Create: `web/next.config.ts`
- Create: `web/postcss.config.mjs`
- Create: `web/.gitignore`
- Create: `web/.dockerignore`
- Create: `web/next-env.d.ts`
- [ ] **Step 1: Create `web/` and write `package.json`**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web
```
Create `web/package.json`:
```json
{
"name": "cm-web-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.0.0",
"@types/node": "22.10.0",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"tailwindcss": "4.0.0",
"typescript": "5.7.0"
}
}
```
The pinned versions are mid-2024-stable Next.js 15 + React 19 + Tailwind v4 final. Lockfile (Step 6) will resolve transitive deps.
- [ ] **Step 2: Write `web/tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
- [ ] **Step 3: Write `web/next.config.ts`**
```typescript
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
trailingSlash: true,
};
export default config;
```
- [ ] **Step 4: Write `web/postcss.config.mjs`**
```javascript
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
```
- [ ] **Step 5: Write `web/.gitignore` and `web/.dockerignore`**
`web/.gitignore`:
```
node_modules/
/.next/
/out/
/build/
.DS_Store
*.tsbuildinfo
next-env.d.ts.bak
.env*.local
```
`web/.dockerignore`:
```
node_modules
.next
.git
.gitignore
README.md
```
- [ ] **Step 6: Write `web/next-env.d.ts`**
This file is normally auto-generated by Next.js; committing it verbatim avoids a phantom diff every build.
```typescript
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
```
- [ ] **Step 7: Install dependencies and generate the lockfile**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npm install --no-audit --no-fund 2>&1 | tail -10
```
Expected: completion line like `added <N> packages` and a new `web/package-lock.json`. Errors at this step usually mean the version pins above don't co-resolve; bump the offender to its latest patch and rerun.
- [ ] **Step 8: Verify the build chain works on configs alone**
`next build` requires at least one route. Defer the build smoke to Task 3 once we have `app/page.tsx` and the route handler. For now, sanity-check that `npx tsc --noEmit` doesn't error:
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npx tsc --noEmit && echo "tsc OK"
```
Expected: `tsc OK` (no output from tsc, since there are no .ts files yet — just configs).
- [ ] **Step 9: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/package.json web/package-lock.json web/tsconfig.json web/next.config.ts web/postcss.config.mjs web/.gitignore web/.dockerignore web/next-env.d.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): bootstrap Next.js 15 project configs and lockfile"
```
---
## Task 2: Generate `layout.tsx` and `page.tsx` via `frontend-design`
**Files:**
- Create: `web/app/layout.tsx` (via frontend-design)
- Create: `web/app/page.tsx` (via frontend-design)
- Create: `web/app/globals.css`
- [ ] **Step 1: Write `web/app/globals.css`**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app
```
Create `web/app/globals.css`:
```css
@import "tailwindcss";
```
- [ ] **Step 2: Invoke the `frontend-design` skill**
Use the Skill tool with `skill="frontend-design:frontend-design"` and the following brief verbatim (from the B1 spec's "Empty UI page" section):
```
Generate two files for a Next.js 15 App Router project that uses Tailwind v4
(already configured via `@import "tailwindcss";` in `app/globals.css`):
1. `app/layout.tsx` — minimal root layout. <html lang="en">; <body> with
Tailwind defaults (no custom font). Tab title: "CM Bot V2". Imports
`./globals.css`. Server Component. No metadata API beyond `title`.
2. `app/page.tsx` — a scaffold-confirmation placeholder for the
`cm-web-next` service. Required content:
- Product name "CM Bot V2"
- Literal text "cm-web-next scaffold" (operators grep for this)
- One-line note that the real dashboard lands in B2
- An obvious link to /api/414322309db5c06d/ for smoke-testing the proxy
(this is the SHA-256[:16] of "acc"; see web/lib/api-paths.ts)
Constraints:
- Tailwind v4 utility classes only — no external font, image, or icon deps.
- Server Component (no "use client", no JS interactivity).
- Single page, no navigation.
- Should clearly read as a temporary scaffold, not a real dashboard. The
visual treatment should signal "work-in-progress / placeholder" so a
user landing here doesn't mistake it for the production UI.
- Mobile-first responsive defaults; no dark mode, no animations.
Out of scope: dark mode, multi-route navigation, charts/tables, animations.
```
The skill will return TSX content for the two files. Save the returned `layout.tsx` to `web/app/layout.tsx` and the returned `page.tsx` to `web/app/page.tsx`.
- [ ] **Step 3: Verify the generated files compile and the page renders**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npm run build 2>&1 | tail -20
```
Expected:
- A `Compiled successfully` line (or equivalent for Next.js 15).
- A route summary line for `/` (the page) and any other auto-generated routes.
- No TypeScript errors. If any appear, the issue is in the generated TSX — re-invoke `frontend-design` with the additional constraint "fix this TS error: <paste>" and replace.
- [ ] **Step 4: Verify the placeholder text is present**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
grep -E "cm-web-next scaffold|CM Bot V2" app/page.tsx app/layout.tsx
```
Expected: hits in both files. Specifically `page.tsx` should contain the literal string `cm-web-next scaffold` (operators search for this) and a reference to `/api/acc/` (the smoke-test link).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/layout.tsx web/app/page.tsx web/app/globals.css && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add scaffold layout and page (frontend-design generated)"
```
---
## Task 3: API path mapping + catch-all Route Handler proxy
**Files:**
- Create: `web/lib/api-paths.ts`
- Create: `web/app/api/[...path]/route.ts`
- [ ] **Step 1: Create `web/lib/api-paths.ts`**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/lib
```
Create `web/lib/api-paths.ts` with the precomputed hashes:
```typescript
// SHA-256(endpoint).hex[:16]. Deterministic; no salt. Public-boundary only:
// the upstream api-server still uses the readable names. See B1 spec.
export const API_PATHS = {
acc: "414322309db5c06d", // upstream: /acc/
user: "04f8996da763b7a9", // upstream: /user/
updateAcc: "982830e2982d95de", // upstream: /update-acc-data
updateUser: "f1a25b37d8db494c", // upstream: /update-user-data
} as const;
// Reverse map for the Route Handler. Keys are the public path segment,
// values are { upstream: string; trailingSlash: boolean }.
export const PUBLIC_TO_UPSTREAM: Record<string, { upstream: string; trailingSlash: boolean }> = {
[API_PATHS.acc]: { upstream: "acc", trailingSlash: true },
[API_PATHS.user]: { upstream: "user", trailingSlash: true },
[API_PATHS.updateAcc]: { upstream: "update-acc-data", trailingSlash: false },
[API_PATHS.updateUser]: { upstream: "update-user-data", trailingSlash: false },
};
```
Verify the hashes (so future readers can double-check):
```bash
.venv/bin/python3 -c "
import hashlib
for endpoint in ['acc', 'user', 'update-acc-data', 'update-user-data']:
print(f'{endpoint:24s} -> {hashlib.sha256(endpoint.encode()).hexdigest()[:16]}')
"
```
Expected output (matches the constants above):
```
acc -> 414322309db5c06d
user -> 04f8996da763b7a9
update-acc-data -> 982830e2982d95de
update-user-data -> f1a25b37d8db494c
```
- [ ] **Step 2: Create the route handler directory**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/api/\[...path\]
```
(The square brackets are Next.js's catch-all dynamic segment syntax. Shell-escape with backslashes.)
- [ ] **Step 3: Write the route handler**
Create `web/app/api/[...path]/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { PUBLIC_TO_UPSTREAM } from "@/lib/api-paths";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
async function forward(
request: NextRequest,
path: string[],
): Promise<NextResponse> {
const hash = path[0];
const route = PUBLIC_TO_UPSTREAM[hash];
if (!route) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
const target = `${API_BASE_URL}/${route.upstream}${route.trailingSlash ? "/" : ""}`;
const init: RequestInit = {
method: request.method,
headers: {
"content-type":
request.headers.get("content-type") ?? "application/json",
},
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.text();
}
try {
const upstream = await fetch(target, init);
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
},
});
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function GET(
request: NextRequest,
ctx: { params: Promise<{ path: string[] }> },
): Promise<NextResponse> {
return forward(request, (await ctx.params).path);
}
export async function POST(
request: NextRequest,
ctx: { params: Promise<{ path: string[] }> },
): Promise<NextResponse> {
return forward(request, (await ctx.params).path);
}
```
Mapped requests (`GET /api/414322309db5c06d/` etc.) resolve through `PUBLIC_TO_UPSTREAM` and forward to `api-server:3000/acc/` etc. Unmapped hashes return 404 — they're literally not routes we expose. Network/upstream errors return `{"error": <stringified-error>}` with HTTP 500, matching `cm_web_view.py`'s shape.
- [ ] **Step 3: Build to verify the route compiles**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npm run build 2>&1 | tail -25
```
Expected: route summary now includes `/api/[...path]` (or similar). No TS errors.
- [ ] **Step 4: Build and verify the route compiles**
```bash
cd /home/yiekheng/projects/cm_bot_v2/web && \
npm run build 2>&1 | tail -25
```
Expected: route summary now includes `/api/[...path]`. No TS errors. The `@/lib/api-paths` import resolves via the `paths` mapping in `tsconfig.json`.
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/lib/api-paths.ts web/app/api/\[...path\]/route.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): hash-encoded API paths + catch-all Route Handler proxy"
```
---
## Task 4: Multi-stage Dockerfile for `cm-web-next`
**Files:**
- Create: `docker/web-next/Dockerfile`
- [ ] **Step 1: Create the Dockerfile directory and file**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/docker/web-next
```
Create `docker/web-next/Dockerfile`:
```dockerfile
# syntax=docker/dockerfile:1.7
# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY web/package.json web/package-lock.json* ./
RUN npm ci
# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/ ./
RUN npm run build
# --- runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
```
`web/public/` doesn't exist yet but the `COPY public ./public` step is harmless if Next.js 15 didn't create it during build (the standalone output bundles public). If `npm run build` fails because `public/` is missing, create the directory empty:
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/public
touch /home/yiekheng/projects/cm_bot_v2/web/public/.gitkeep
```
- [ ] **Step 2: (Optional) Verify the Dockerfile builds locally**
This is optional because it requires docker on the engineer's machine. If available:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker build -f docker/web-next/Dockerfile -t cm-web-next:plan-test . 2>&1 | tail -20
```
Expected: a "Successfully tagged cm-web-next:plan-test" line. If the build fails because of a missing `web/public/` directory, run the `mkdir -p web/public` from Step 1's note and rebuild. Skip this step if docker isn't available locally; Task 8 covers the integration verification.
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker/web-next/Dockerfile $(test -d web/public && echo "web/public/.gitkeep") && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(docker): add multi-stage Dockerfile for cm-web-next"
```
---
## Task 5: Add `web-next` to compose
**Files:**
- Modify: `docker-compose.yml`
- Modify: `docker-compose.override.yml`
- [ ] **Step 1: Add `web-next` service to base compose**
Find the existing `transfer-bot:` block in `docker-compose.yml` and add the `web-next:` block immediately above it (so the natural reading order is: telegram-bot → api-server → web-view → web-next → transfer-bot). The new block:
```yaml
# Next.js Web View (side-by-side with web-view during B-cycle migration).
web-next:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
restart: unless-stopped
ports:
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
API_BASE_URL: http://api-server:3000
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- bot-network
depends_on:
- api-server
```
- [ ] **Step 2: Add the build directive in the override**
In `docker-compose.override.yml`, append to the `services:` section (after the existing `transfer-bot:` block, before any top-level keys like `volumes:`):
```yaml
web-next:
build:
context: .
dockerfile: docker/web-next/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
```
No `command:` override and no `profiles:``web-next` is part of the dev stack like `web-view`.
- [ ] **Step 3: Validate both compose files**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.yml') as f:
base = yaml.safe_load(f)
assert 'web-next' in base['services'], 'web-next missing from base'
wn = base['services']['web-next']
assert wn['ports'] == ['\${CM_WEB_NEXT_HOST_PORT:-8010}:3000']
assert wn['environment']['API_BASE_URL'] == 'http://api-server:3000'
assert wn['depends_on'] == ['api-server']
print('base config: web-next service wired correctly')
with open('docker-compose.override.yml') as f:
over = yaml.safe_load(f)
assert 'web-next' in over['services']
wn_over = over['services']['web-next']
assert wn_over['build']['dockerfile'] == 'docker/web-next/Dockerfile'
print('override: web-next build directive present')
"
```
Expected:
```
base config: web-next service wired correctly
override: web-next build directive present
```
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.yml docker-compose.override.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(compose): add web-next service (side-by-side with web-view)"
```
---
## Task 6: Update env example files
**Files:**
- Modify: `envs/dev/.env.example`
- Modify: `envs/rex/.env.example`
- Modify: `envs/siong/.env.example`
- [ ] **Step 1: Update `envs/dev/.env.example`**
Find the `CM_WEB_HOST_PORT=8000` line in the `=== Deployment Identity ===` section and add `CM_WEB_NEXT_HOST_PORT=8010` immediately after it:
```
# === Deployment Identity ===
CM_DEPLOY_NAME=dev-cm
CM_WEB_HOST_PORT=8000
CM_WEB_NEXT_HOST_PORT=8010
```
- [ ] **Step 2: Update `envs/rex/.env.example`**
Add `CM_WEB_NEXT_HOST_PORT=8011` after `CM_WEB_HOST_PORT=8001`:
```
# === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001
CM_WEB_NEXT_HOST_PORT=8011
```
- [ ] **Step 3: Update `envs/siong/.env.example`**
Add `CM_WEB_NEXT_HOST_PORT=8012` after `CM_WEB_HOST_PORT=8005`:
```
# === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005
CM_WEB_NEXT_HOST_PORT=8012
```
- [ ] **Step 4: Verify all three files agree on the new key**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -H "^CM_WEB_NEXT_HOST_PORT=" envs/*/.env.example
```
Expected: three lines, one per deployment, each with a distinct port (8010 / 8011 / 8012).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add envs/dev/.env.example envs/rex/.env.example envs/siong/.env.example && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(envs): add CM_WEB_NEXT_HOST_PORT to all .env.example templates"
```
---
## Task 7: Wire `web-next` into `dev.sh` and `publish.sh`
**Files:**
- Modify: `scripts/dev.sh`
- Modify: `scripts/publish.sh`
- [ ] **Step 1: Update `dev.sh`**
In `scripts/dev.sh`, find the three places that currently pass the explicit service list and add `web-next`:
`up` case (currently `mysql api-server web-view`):
```bash
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
"${COMPOSE[@]}" ps
;;
```
`reset-db` case (the inner `up` invocation):
```bash
reset-db)
"${COMPOSE[@]}" down --volumes --remove-orphans
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
;;
```
`logs` case:
```bash
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
;;
```
The `status` case stays as-is — it only checks `mysql`.
Update `usage()` so the help text mentions the new service:
```bash
usage() {
cat <<'EOF'
Lifecycle for the dev stack (mysql + api-server + web-view + web-next).
Usage:
scripts/dev.sh up Start all dev services in the background.
scripts/dev.sh down Stop the stack. mysql volume kept (DB persists).
scripts/dev.sh reset-db Stop the stack AND drop the mysql volume; then start.
scripts/dev.sh logs Tail logs from the running stack.
scripts/dev.sh status Print 'OK' if mysql is running, else exit 1.
Environment:
NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group).
EOF
}
```
- [ ] **Step 2: Update `publish.sh`**
In `scripts/publish.sh`, find the `SERVICES=` array (currently four entries) and append `web-next`:
```bash
SERVICES=(
"api docker/api/Dockerfile"
"telegram docker/telegram/Dockerfile"
"web docker/web/Dockerfile"
"transfer docker/transfer/Dockerfile"
"web-next docker/web-next/Dockerfile"
)
```
The image-name template `${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}` produces `gitea.04080616.xyz/yiekheng/cm-web-next:<tag>` — matching the compose `image:` reference.
- [ ] **Step 3: Bash syntax-check both scripts**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
bash -n scripts/dev.sh && bash -n scripts/publish.sh && echo "syntax OK"
```
Expected: `syntax OK`.
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add scripts/dev.sh scripts/publish.sh && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(scripts): include web-next in dev.sh and publish.sh"
```
---
## Task 8: AGENTS.md updates
**Files:**
- Modify: `AGENTS.md`
- [ ] **Step 1: Add `web/` to the Project Structure section**
Find the existing `## Project Structure & Module Organization` section and add a new bullet after the `app/` bullets, immediately above the `docker/<service>/Dockerfile` line:
Find:
```
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-telegram`, `cm-transfer`).
```
Replace with:
```
- `web/` is the Next.js 15 app for the new web view (`cm-web-next` service). Tailwind v4, App Router, TypeScript. Side-by-side with the legacy Flask `cm_web_view.py` until B4 cuts over.
- `docker/<service>/Dockerfile` builds one image per service (`cm-api`, `cm-web`, `cm-web-next`, `cm-telegram`, `cm-transfer`).
```
- [ ] **Step 2: Update the Dev Tier section's URL note**
In the existing `## Dev Tier (Local Development)` section, find the bullet that mentions the lifecycle script:
```
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
```
Add a follow-up bullet describing the new URL:
```
- Lifecycle: `bash scripts/dev.sh {up,down,reset-db,logs,status}`.
- URLs: `http://localhost:8000/` (legacy Flask UI), `http://localhost:8010/` (new Next.js scaffold). Both run side-by-side until the B4 cutover retires the Flask version.
```
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): document web/ Next.js project and cm-web-next dev URL"
```
---
## Task 9: Integration verification (deployer host required)
This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If anything fails, debug before declaring done.
**Files:** none modified.
**Prerequisites:** docker compose v2 plugin installed; the engineer's `.env` has `CM_WEB_NEXT_HOST_PORT` set (default 8010 if absent thanks to `${CM_WEB_NEXT_HOST_PORT:-8010}` in the compose interpolation).
- [ ] **Step 1: Bring up the dev stack**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
bash scripts/dev.sh up
```
Wait ~2535s (mysql healthcheck + npm/Next.js startup). Then:
```bash
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml ps
```
Expected: five containers running — `dev-cm-mysql`, `dev-cm-api-server`, `dev-cm-web-view`, `dev-cm-web-next`, plus a healthy `mysql`.
- [ ] **Step 2: Empty page renders**
```bash
curl -s http://localhost:8010/ | grep -E "cm-web-next scaffold|CM Bot V2"
```
Expected: hits in the response. Open `http://localhost:8010/` in a browser — page is visible, looks like a placeholder, has a link to `/api/acc/`.
- [ ] **Step 3: API proxy parity (GET)**
```bash
# Old Flask: /api/acc/. New Next.js: /api/<sha256("acc")[:16]>/.
diff \
<(curl -s http://localhost:8000/api/acc/) \
<(curl -s http://localhost:8010/api/414322309db5c06d/) \
&& echo "GET parity OK"
```
Expected: `GET parity OK`. Both go through to `api-server:3000/acc/` and return the same JSON. The hash mapping is the only thing in front of the upstream — same body, same status.
- [ ] **Step 4: API proxy parity (POST)**
```bash
diff \
<(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"13c1000","password":"x","status":"","link":""}' \
http://localhost:8000/api/update-acc-data) \
<(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"username":"13c1000","password":"x","status":"","link":""}' \
http://localhost:8010/api/982830e2982d95de) \
&& echo "POST parity OK"
```
Expected: `POST parity OK`. Both POSTs round-trip through to `api-server:3000/update-acc-data`.
- [ ] **Step 4b: Unmapped hash returns 404**
```bash
curl -s -o /dev/null -w "code=%{http_code}\n" http://localhost:8010/api/deadbeefdeadbeef/
```
Expected: `code=404`. Confirms the proxy is allowlist-based, not pass-through.
- [ ] **Step 5: Old `cm-web` still serves**
```bash
curl -sf http://localhost:8000/ | head -c 200; echo
```
Expected: HTML containing the existing Flask `<title>CM Bot Database Viewer</title>` (unchanged).
- [ ] **Step 6: Image is buildable through `publish.sh`**
```bash
bash scripts/publish.sh --help | head -5
```
Expected: usage block lists `web-next` (or at least no errors). Optional full publish: `bash scripts/publish.sh dev-test` after `docker login gitea.04080616.xyz`.
- [ ] **Step 7: Prod compose parity check**
```bash
sudo docker compose -f docker-compose.yml config | grep -E "web-next:|web-view:|api-server:" | head
sudo docker compose -f docker-compose.yml config | grep -E "8010|8001|3000:3000" | head
```
Expected: `web-next:` listed alongside the other services; web-next bound on `${CM_WEB_NEXT_HOST_PORT:-8010}:3000`; api-server has no host port (preserved from C5).
- [ ] **Step 8: Tear down**
```bash
bash scripts/dev.sh down
```
Expected: clean shutdown (no orphan complaints — `--remove-orphans` from the C cycle handles it).
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| `web/` directory at repo root, full Next.js project | Task 1 |
| Next.js 15 + App Router + TypeScript + Tailwind v4 | Task 1 (configs) + Task 2 (app shell) |
| `output: "standalone"`, `trailingSlash: true` in `next.config.ts` | Task 1 step 3 |
| `frontend-design`-generated `layout.tsx` and `page.tsx` | Task 2 step 2 |
| Hash-encoded API paths at the public boundary | Task 3 |
| `web/lib/api-paths.ts` shared mapping for server + client | Task 3 step 1 |
| Catch-all proxy at `web/app/api/[...path]/route.ts` (allowlist-based) | Task 3 step 3 |
| Unmapped hash returns 404 (not pass-through) | Task 9 step 4b |
| Multi-stage Dockerfile (Node 22 alpine, standalone output) | Task 4 |
| `web-next` service in base compose, `${CM_WEB_NEXT_HOST_PORT:-8010}:3000` | Task 5 step 1 |
| Build directive in override | Task 5 step 2 |
| `CM_WEB_NEXT_HOST_PORT` in dev/rex/siong .env.example (8010/8011/8012) | Task 6 |
| `dev.sh` includes `web-next` in up/logs/reset-db | Task 7 step 1 |
| `publish.sh` adds `web-next` image | Task 7 step 2 |
| AGENTS.md updates | Task 8 |
| Side-by-side preserved (cm-web Flask untouched) | Verified across Tasks 5, 9 |
| Integration verification | Task 9 |
No gaps. No placeholders. Type names (`NextRequest`, `NextResponse`) and config keys (`output`, `trailingSlash`, `API_BASE_URL`, `CM_WEB_NEXT_HOST_PORT`) consistent across tasks. The frontend-design invocation is the only step where the produced TSX content isn't quoted verbatim — by design, since the skill is the source of authority for the design.

View File

@ -0,0 +1,747 @@
# B2+B3: UI Port + PWA Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the legacy Flask `cm_web_view.py` UI to Next.js (RSC reads + Server Actions for inline edits, two URL-based tabs) and ship the PWA manifest + icons so operators can install on their phones.
**Architecture:** Hand-write the glue (types, fetch helpers, Server Actions, manifest, page wrappers, auto-refresh) and invoke `frontend-design` three times for the UI code (data tables, frame, icons). No Docker/compose changes — `COPY web/ ./` already bundles the new files.
**Tech Stack:** Next.js 15 (App Router) + React 19 (Server Components, Server Actions, `useOptimistic`) + Tailwind v4. `next/og`'s `ImageResponse` renders the PWA icons at build time — no external SVG-to-PNG tooling.
**Spec:** [docs/superpowers/specs/2026-05-02-b2-b3-ui-port-pwa-design.md](../specs/2026-05-02-b2-b3-ui-port-pwa-design.md)
---
## File Map
| File | Operation | Owner | Depends on |
|---|---|---|---|
| `web/lib/types.ts` | Create | hand | — |
| `web/lib/api.ts` | Create | hand | `lib/types` |
| `web/app/actions.ts` | Create | hand | `lib/api`, `lib/types` |
| `web/components/auto-refresh.tsx` | Create | hand | — |
| `web/components/editable-cell.tsx` | Create | frontend-design | — |
| `web/components/accounts-table.tsx` | Create | frontend-design | `lib/types`, `actions`, `editable-cell` |
| `web/components/users-table.tsx` | Create | frontend-design | `lib/types`, `actions`, `editable-cell` |
| `web/components/nav.tsx` | Create | frontend-design | — |
| `web/app/layout.tsx` | Rewrite | frontend-design | `nav`, `auto-refresh` |
| `web/app/error.tsx` | Create | frontend-design | — |
| `web/app/page.tsx` | Rewrite | hand | `lib/api`, `accounts-table` |
| `web/app/users/page.tsx` | Create | hand | `lib/api`, `users-table` |
| `web/app/manifest.ts` | Create | hand | — |
| `web/app/icon.tsx` | Create | frontend-design | — |
| `web/app/apple-icon.tsx` | Create | frontend-design | — |
Three `frontend-design` invocations, scoped:
1. **Data primitives**`editable-cell.tsx`, `accounts-table.tsx`, `users-table.tsx` (Task 5).
2. **Frame**`layout.tsx`, `nav.tsx`, `error.tsx` (Task 7).
3. **Icons**`icon.tsx`, `apple-icon.tsx` (Task 9).
Each invocation gets a focused brief so the output stays coherent.
---
## Task 1: Hand-write types
**Files:**
- Create: `web/lib/types.ts`
- [ ] **Step 1: Create the directory**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/lib
```
- [ ] **Step 2: Write `web/lib/types.ts`**
```typescript
// Mirrors the SQL schema in docker/mysql/init.d/01-schema.sql and the
// JSON projection from app/cm_api.py's get_account / get_user routes.
export type Acc = {
username: string;
password: string;
status: string;
link: string;
};
export type User = {
f_username: string;
f_password: string;
t_username: string;
t_password: string;
last_update_time: string | null;
};
export type AccUpdate = Acc;
export type UserUpdate = Pick<
User,
"f_username" | "f_password" | "t_username" | "t_password"
>;
```
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/lib/types.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add TypeScript types for Acc and User"
```
---
## Task 2: Hand-write the api-server fetch helper
**Files:**
- Create: `web/lib/api.ts`
- [ ] **Step 1: Write `web/lib/api.ts`**
```typescript
import type { Acc, User } from "./types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
type FetchInit = {
method?: "GET" | "POST";
body?: unknown;
cache?: RequestCache;
};
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
method: options.method ?? "GET",
cache: options.cache ?? "no-store",
headers: options.body ? { "content-type": "application/json" } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
};
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
}
return res.json();
}
export async function getAccounts(): Promise<Acc[]> {
const data = await fetchApi("/acc/");
return data as Acc[];
}
export async function getUsers(): Promise<User[]> {
const data = await fetchApi("/user/");
return data as User[];
}
```
`cache: "no-store"` is critical — RSC's default caching would stale the dashboard between page loads.
- [ ] **Step 2: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/lib/api.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add server-side api-server fetch helper"
```
---
## Task 3: Hand-write Server Actions
**Files:**
- Create: `web/app/actions.ts`
- [ ] **Step 1: Write `web/app/actions.ts`**
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";
import type { AccUpdate, UserUpdate } from "@/lib/types";
export type ActionResult = { ok: true } | { ok: false; error: string };
export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-acc-data", { method: "POST", body: data });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function updateUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-user-data", { method: "POST", body: data });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
```
The `"use server"` directive at the top makes these functions invocable from client components via Next.js's Server Actions wire format. `revalidatePath` re-runs the matching page's RSC fetch and patches the result in.
- [ ] **Step 2: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/actions.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add updateAccount and updateUser Server Actions"
```
---
## Task 4: Hand-write the auto-refresh client component
**Files:**
- Create: `web/components/auto-refresh.tsx`
- [ ] **Step 1: Create the directory and file**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/components
```
Create `web/components/auto-refresh.tsx`:
```typescript
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
/**
* Mounts a setInterval that calls router.refresh() every `intervalMs`.
* router.refresh() re-runs the matching Server Component fetch and
* patches the rendered output in — no full page reload, no flicker.
*
* Renders nothing.
*/
export default function AutoRefresh({
intervalMs = 30_000,
}: {
intervalMs?: number;
}) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [router, intervalMs]);
return null;
}
```
- [ ] **Step 2: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/components/auto-refresh.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add 30s auto-refresh client component"
```
---
## Task 5: Invoke `frontend-design` for the data primitives
**Files:**
- Create: `web/components/editable-cell.tsx`
- Create: `web/components/accounts-table.tsx`
- Create: `web/components/users-table.tsx`
- [ ] **Step 1: Invoke the `frontend-design` skill**
Use the Skill tool with `skill="frontend-design:frontend-design"` and the following brief:
```
Generate three React Client Components for a Next.js 15 + Tailwind v4
internal CRUD dashboard. The dashboard lives at `web/components/` and
ports a legacy Flask UI (gradient purple, FontAwesome, inline cell
editing) to a polished modern look that reads as a real production
dashboard. The previous scaffold used a brutalist hazard-tape aesthetic;
THIS UI is the production replacement and should look distinct from the
scaffold while staying in the same design family if possible (or pivot
fully to a new direction if that serves the data better).
Constraints — every file:
- "use client" at the top.
- Tailwind v4 utility classes only — no external font, image, or icon deps.
- TypeScript with strict types from `@/lib/types`.
- Mobile-first responsive: tables collapse to card stacks below 640px.
- Server Actions imported from `@/app/actions` for mutations.
- React 19 useOptimistic for inline-edit feedback (instant local update,
reverts on Server Action failure).
- No external state libraries — useState / useOptimistic / useTransition
only.
File 1: `web/components/editable-cell.tsx`
Generic primitive: a span/td that turns into a text input on click,
saves on Enter / Save button, cancels on Escape / Cancel button.
Props:
type EditableCellProps = {
value: string;
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
label?: string; // accessibility label, e.g. "edit password"
isCurrentlyEditing?: boolean;
onEditStart?: () => void;
onEditEnd?: () => void;
};
Behavior:
- Click → input, focus + select all.
- Enter or Save button → calls onSave, optimistically updates display
immediately, reverts on { ok: false } and shows the error inline for
a few seconds.
- Escape or Cancel button → restores original value.
- Hover affordance (subtle visual cue that the cell is editable).
- isCurrentlyEditing/onEditStart/onEditEnd let a parent table track
which cell is in edit mode (so auto-refresh can pause).
File 2: `web/components/accounts-table.tsx`
Renders the Accounts dashboard tab content. Receives initial data from
the server component:
type Props = { initial: Acc[]; prefixPattern: string };
Renders:
- A stats header (count of total accounts).
- A refresh button (calls router.refresh() from next/navigation).
- A sortable table:
- Username column: sortable. Sort logic: rows whose username starts
with `prefixPattern` always sort to the top; within each group,
sort descending by username string.
- Password, Status, Link: editable via EditableCell, calling the
`updateAccount` Server Action with the full Acc object on save.
- Status column shows a colored badge for empty/wait/done states
(three visually distinct variants).
- Below 640px: each row collapses to a card stack with labeled rows.
- Empty state when initial.length === 0: a clear "no accounts" message.
File 3: `web/components/users-table.tsx`
Renders the Users dashboard tab content. Receives:
type Props = { initial: User[]; prefixPattern: string };
Same shape as accounts-table but for the User type:
- Sortable username column with the prefix-priority logic on
user.f_username.
- Then sorted by last_update_time descending (newest first) within
each group.
- f_password, t_username, t_password are editable cells that call
updateUser Server Action.
- last_update_time is read-only.
- f_username is read-only (it's the primary key).
All three files: return TypeScript source ready to drop into the
listed paths. Imports use `@/lib/types`, `@/app/actions`, and
`next/navigation` as appropriate.
```
- [ ] **Step 2: Save the returned files**
The skill returns three TSX files. Save them to:
- `web/components/editable-cell.tsx`
- `web/components/accounts-table.tsx`
- `web/components/users-table.tsx`
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/components/editable-cell.tsx web/components/accounts-table.tsx web/components/users-table.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add data tables and editable-cell primitive (frontend-design)"
```
---
## Task 6: Hand-write page wrappers (RSC entry points)
**Files:**
- Rewrite: `web/app/page.tsx`
- Create: `web/app/users/page.tsx`
- [ ] **Step 1: Replace `web/app/page.tsx`**
The current file is the B1 brutalist scaffold. Replace it entirely with the accounts entry-point wrapper:
```typescript
import { getAccounts } from "@/lib/api";
import AccountsTable from "@/components/accounts-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function AccountsPage() {
const accounts = await getAccounts();
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
}
```
`PREFIX_PATTERN` reads from `NEXT_PUBLIC_CM_PREFIX_PATTERN` (which gets baked into the client bundle since `NEXT_PUBLIC_` prefixed env vars are exposed to the browser). The default `13c` matches `envs/dev/.env.example`'s `CM_PREFIX_PATTERN`.
- [ ] **Step 2: Create `web/app/users/page.tsx`**
```bash
mkdir -p /home/yiekheng/projects/cm_bot_v2/web/app/users
```
Then:
```typescript
import { getUsers } from "@/lib/api";
import UsersTable from "@/components/users-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function UsersPage() {
const users = await getUsers();
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
}
```
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/page.tsx web/app/users/page.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add Server Component entry pages for / and /users"
```
---
## Task 7: Invoke `frontend-design` for the frame (layout, nav, error)
**Files:**
- Rewrite: `web/app/layout.tsx`
- Create: `web/components/nav.tsx`
- Create: `web/app/error.tsx`
- [ ] **Step 1: Invoke `frontend-design`**
Use the Skill tool with `skill="frontend-design:frontend-design"` and:
```
Generate three TSX files for a Next.js 15 + Tailwind v4 internal
dashboard. The dashboard has two URL-based tabs (`/` for Accounts,
`/users` for Users). The visual identity should match the data-table
components already designed (accounts-table.tsx, users-table.tsx) —
they use a polished production-dashboard aesthetic, distinct from the
brutalist hazard-tape scaffold that preceded them.
File 1: `web/app/layout.tsx` — root layout (Server Component).
Required:
- Imports `./globals.css`.
- `export const metadata` includes `title: "CM Bot V2"` and a
`themeColor` matching the chosen aesthetic (the value also goes into
`web/app/manifest.ts` separately — pick a single hex and document it
so I can mirror it).
- Renders <html lang="en"> > <body> > <Nav /> > <main>{children}</main>
+ a single <AutoRefresh /> imported from "@/components/auto-refresh".
- Standard root layout structure for App Router; no per-page metadata
here.
File 2: `web/components/nav.tsx` — top nav (Client Component, "use client",
because it uses usePathname() to highlight the active tab).
Required:
- Two links: "Accounts" → "/", "Users" → "/users".
- Uses next/link for client-side navigation.
- Active tab gets a clear visual highlight (matching the dashboard
aesthetic).
- Mobile-friendly: two equal-width links on narrow screens.
- A small product mark on the left ("CM Bot V2" or similar) — purely
decorative, no navigation action.
File 3: `web/app/error.tsx` — top-level error boundary (Client Component,
"use client"; Next.js requires this).
Required:
- Receives { error, reset } props per Next.js's error boundary contract.
- Visual: clear message ("Couldn't reach the API"), explanation that
api-server may be unreachable, a prominent "Retry" button that calls
reset(). Optional: small monospace block showing error.message and
error.digest for the operator's eye.
- Matches the dashboard aesthetic. Keep tone: this is an operator tool
— apologetic copy is wrong; informative is right.
All three files: TypeScript source ready to drop into the paths listed.
Use `@/components/auto-refresh` and `@/components/nav` as imports where
relevant.
```
- [ ] **Step 2: Save the returned files**
Save to:
- `web/app/layout.tsx` (overwrite the B1 scaffold version)
- `web/components/nav.tsx`
- `web/app/error.tsx`
Note the chosen `themeColor` hex — Task 8 hardcodes the same value into `manifest.ts`.
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/layout.tsx web/components/nav.tsx web/app/error.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add layout, nav, and error boundary (frontend-design)"
```
---
## Task 8: Hand-write the PWA manifest
**Files:**
- Create: `web/app/manifest.ts`
- [ ] **Step 1: Write `web/app/manifest.ts`**
Use the `themeColor` hex chosen by frontend-design in Task 7. Replace `#000000` below with the actual value.
```typescript
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "CM Bot V2",
short_name: "CM Bot",
description: "CM Bot account and user dashboard",
start_url: "/",
display: "standalone",
orientation: "portrait",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{ src: "/icon", sizes: "any", type: "image/png" },
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
],
};
}
```
Next.js auto-serves this at `/manifest.webmanifest`. The `/icon` and `/apple-icon` URLs are auto-generated from `app/icon.tsx` and `app/apple-icon.tsx` (created in Task 9).
- [ ] **Step 2: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/manifest.ts && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add PWA manifest config"
```
---
## Task 9: Invoke `frontend-design` for the PWA icons
**Files:**
- Create: `web/app/icon.tsx`
- Create: `web/app/apple-icon.tsx`
- [ ] **Step 1: Invoke `frontend-design`**
Use the Skill tool with `skill="frontend-design:frontend-design"` and:
```
Generate two TSX files that produce PWA icons via Next.js 15's
ImageResponse API (from "next/og"). The icons should match the visual
identity of the dashboard frame designed earlier (theme color hex was
<paste the hex chosen in Task 7>; layout is a polished production
dashboard, not the brutalist scaffold).
File 1: `web/app/icon.tsx` — Android home-screen / desktop favicon.
```typescript
import { ImageResponse } from "next/og";
export const size = { width: 512, height: 512 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
// JSX here: a single <div> styled with inline `style={{...}}`
// that contains a brand mark on the chosen theme color
// background. ImageResponse uses Satori, which only supports
// a tiny subset of CSS via inline `style` — no Tailwind, no
// CSS classes, no <main>/<section>; just <div> + <span>.
),
size,
);
}
```
Brand mark requirements:
- The literal text "CM" is the safest bet (the product is "CM Bot V2";
initials read as a logo at small sizes).
- Single color, high contrast against the background.
- Centered, generous padding.
- No font dep — Satori falls back to a system font if none is supplied.
File 2: `web/app/apple-icon.tsx` — iOS home-screen icon.
Same shape as icon.tsx but with `size = { width: 180, height: 180 }`.
iOS does NOT honor the manifest icon list reliably; this file is what
shows up when a user does "Add to Home Screen" on Safari.
Constraints (both files):
- Use ONLY inline `style` (Satori does not support Tailwind classes or
external stylesheets — this is a hard constraint of `ImageResponse`).
- No imports beyond `next/og`'s ImageResponse.
- Keep the JSX tree shallow (a wrapper div + the brand mark).
```
- [ ] **Step 2: Save the returned files**
Save to:
- `web/app/icon.tsx`
- `web/app/apple-icon.tsx`
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add web/app/icon.tsx web/app/apple-icon.tsx && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): add PWA icons via Next.js ImageResponse (frontend-design)"
```
---
## Task 10: Build + smoke verification
**Files:** none modified.
This task is the integration verification. Requires docker compose v2 on the deploy host.
- [ ] **Step 1: Rebuild the cm-web-next image**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml down --remove-orphans && \
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next 2>&1 | tail -30
```
Expected: a successful build, ending with a "FINISHED" line. If `npm run build` fails inside the container, the error message points to the offending TSX. Common causes:
- frontend-design's TSX uses a dependency we didn't install. Add the dep to `web/package.json`, rebuild.
- Type mismatch between `lib/types.ts` and what a component expects. Reconcile.
- [ ] **Step 2: Bring the dev stack up**
```bash
bash scripts/dev.sh up
```
Wait ~15s for the healthcheck.
```bash
bash scripts/dev.sh status
```
Expected: `OK`.
- [ ] **Step 3: Smoke — `/` renders the accounts table**
```bash
curl -sf http://localhost:8010/ | grep -E "13c1000|13c1011"
```
Expected: hits — the seed accounts from `docker/mysql/init.d/02-seed.sql` are visible in the rendered HTML.
- [ ] **Step 4: Smoke — `/users/` renders the users table**
```bash
curl -sf http://localhost:8010/users/ | grep -E "player_one_seed|player_two_seed"
```
Expected: hits — the seeded user pairings show up.
- [ ] **Step 5: Smoke — manifest is served**
```bash
curl -sf http://localhost:8010/manifest.webmanifest | python3 -c "import json,sys; m=json.load(sys.stdin); print(m['name'], m['display'])"
```
Expected: `CM Bot V2 standalone`.
- [ ] **Step 6: Smoke — icons render to PNG**
```bash
curl -sIf http://localhost:8010/icon | grep -i "content-type"
curl -sIf http://localhost:8010/apple-icon | grep -i "content-type"
```
Expected: both return `Content-Type: image/png`.
- [ ] **Step 7: Smoke — `/api/anything` is still 404**
```bash
curl -sf -o /dev/null -w "code=%{http_code}\n" http://localhost:8010/api/anything/
```
Expected: `code=404`. Confirms the architecture pivot stuck — no public API surface.
- [ ] **Step 8: Manual browser check**
Open `http://localhost:8010/` in a browser:
- Accounts table renders with the 4 available + 2 done seed rows.
- Click an editable cell on row `13c1010`. Change a value. Save. The cell updates instantly. After ~1 second, the page revalidates and the new value persists.
- `mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT * FROM acc WHERE username='13c1010'"` shows the new value.
- Click "Users" in the nav. Page navigates to `/users/`, shows the two seeded user pairings.
- Wait 30 seconds with the page open. Browser devtools network tab shows an automatic fetch for the RSC payload (the `auto-refresh.tsx` component firing).
- `sudo docker stop dev-cm-api-server`. Refresh the page in the browser. The error boundary renders. Click Retry. Still errors. `sudo docker start dev-cm-api-server`. Retry. Page recovers.
- [ ] **Step 9: PWA install check (browser)**
In Chrome on desktop or Android, the address bar shows an "Install" affordance. Click it; the app opens chromeless with the icon from `app/icon.tsx`. On Safari iOS, "Add to Home Screen" — the home-screen icon shows the apple-icon.
- [ ] **Step 10: Tear down**
```bash
bash scripts/dev.sh down
```
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| URL-based tabs (`/`, `/users/`) | Task 6 |
| Server Components fetch from api-server | Task 2 (helpers), Task 6 (page wrappers) |
| Server Actions for mutations + revalidatePath | Task 3 |
| useOptimistic on inline edits | Task 5 (editable-cell) |
| Sortable username with prefix-priority | Task 5 (accounts-table, users-table) |
| Status badge for empty/wait/done | Task 5 (accounts-table) |
| Refresh button | Task 5 (accounts-table, users-table) |
| Auto-refresh every 30s | Task 4 (component), Task 7 (mounted in layout) |
| Mobile-first responsive (collapse below 640px) | Task 5 |
| Error boundary for api-server unreachable | Task 7 (error.tsx) |
| Two-tab nav with active highlight | Task 7 (nav.tsx) |
| `app/manifest.ts` PWA manifest | Task 8 |
| `app/icon.tsx` and `app/apple-icon.tsx` via ImageResponse | Task 9 |
| Theme color consistent between layout metadata and manifest | Tasks 7 & 8 (frontend-design picks color in 7; Task 8 mirrors it) |
| Seeded data renders (acc + user) | Task 10 steps 3-4 |
| No public `/api/*` route | Task 10 step 7 |
| Manifest served at `/manifest.webmanifest` | Task 10 step 5 |
| Icons served as image/png | Task 10 step 6 |
| Inline edit round-trips through Server Action and persists in mysql | Task 10 step 8 |
| Error boundary works when api-server stopped | Task 10 step 8 |
| Auto-refresh observable in network tab | Task 10 step 8 |
| PWA installable in Chrome / Safari | Task 10 step 9 |
No gaps. No placeholders. Function names (`updateAccount`, `updateUser`, `getAccounts`, `getUsers`, `fetchApi`, `AutoRefresh`, `EditableCell`) consistent across tasks. Type names (`Acc`, `User`, `AccUpdate`, `UserUpdate`, `ActionResult`) consistent across tasks.

View File

@ -0,0 +1,621 @@
# Debug-Mode Hotfix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace hardcoded `debug=True` in both Flask entrypoints with an env-driven `CM_DEBUG` toggle so the Werkzeug debugger is off by default in rex/siong containers and only enabled when an operator opts in.
**Architecture:** Add a small `_debug_enabled()` helper to `app/cm_api.py` and `app/cm_web_view.py` that reads `CM_DEBUG` from the environment and accepts `1`/`true`/`yes` (case-insensitive) as truthy. Wire `CM_DEBUG: ${CM_DEBUG:-false}` into the Flask service `environment:` blocks in `docker-compose.yml`. Document the variable in `.env.example` and `AGENTS.md`. Gate against regressions with a `unittest`-based test that exercises both copies of the helper.
**Tech Stack:** Python 3.9 (containers), Python 3.12 (local venv), Flask 2.3.3, Docker Compose v2, `unittest` (stdlib — no new dependency).
**Spec:** [docs/superpowers/specs/2026-05-02-debug-mode-hotfix-design.md](../specs/2026-05-02-debug-mode-hotfix-design.md)
---
## File Map
| File | Operation | Purpose |
|---|---|---|
| `tests/__init__.py` | Create | Make `tests/` a package so the test runner can find it. |
| `tests/test_debug_enabled.py` | Create | unittest-based regression tests for the `_debug_enabled` helper in both Flask modules; parametrized so the two copies can't drift. |
| `app/cm_web_view.py` | Modify | Add `_debug_enabled()`; replace `debug=True` in `app.run(...)` at line 748. |
| `app/cm_api.py` | Modify | Add `import os`; add `_debug_enabled()`; change `run()` default to `debug=None` with env resolution. |
| `docker-compose.yml` | Modify | Plumb `CM_DEBUG: ${CM_DEBUG:-false}` into `api-server` and `web-view` env blocks. |
| `.env.example` | Modify | New `Runtime` section documenting `CM_DEBUG`. |
| `AGENTS.md` | Modify | One-line note about `CM_DEBUG` under Security & Configuration Tips. |
The `_debug_enabled` helper is intentionally duplicated rather than placed in a shared module — only two call sites, the parser is six lines, and `app/__init__.py` is just a package marker. The test file enforces parity.
---
## Task 1: Test infrastructure + first failing test for `cm_web_view._debug_enabled`
**Files:**
- Create: `tests/__init__.py`
- Create: `tests/test_debug_enabled.py`
- [ ] **Step 1: Create the test package marker**
Create `tests/__init__.py` empty:
```python
```
- [ ] **Step 2: Write the failing test**
Create `tests/test_debug_enabled.py`:
```python
"""Regression tests for the _debug_enabled helper.
Both app.cm_api and app.cm_web_view define a private _debug_enabled()
function that parses the CM_DEBUG environment variable. They are
intentionally duplicated (only two call sites; no shared utility module
exists). This test runs the same parametrized cases against every copy
to catch drift if one is updated without the other.
"""
import os
import unittest
from unittest import mock
# Import the modules at top-level (before any mock.patch.dict with
# clear=True), so module-load-time os.getenv() reads see the real
# environment. The patches inside individual tests then only affect the
# helper's runtime read of CM_DEBUG.
import app.cm_api
import app.cm_web_view
# Modules expected to expose a private _debug_enabled() helper.
# Add new entries here if more Flask entrypoints adopt the same toggle.
HELPER_MODULES = (
app.cm_web_view,
app.cm_api,
)
# (env_value, expected_result). env_value=None means CM_DEBUG is unset.
CASES = (
(None, False),
("", False),
("false", False),
("False", False),
("FALSE", False),
("0", False),
("no", False),
("anything-else", False),
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("YES", True),
(" true ", True), # whitespace tolerated
)
class DebugEnabledTests(unittest.TestCase):
def _resolve(self, module):
return getattr(module, "_debug_enabled", None)
def test_helper_exists_on_every_module(self):
for module in HELPER_MODULES:
with self.subTest(module=module.__name__):
helper = self._resolve(module)
self.assertTrue(
callable(helper),
f"{module.__name__}._debug_enabled must be callable",
)
def test_parses_cm_debug_consistently(self):
for module in HELPER_MODULES:
helper = self._resolve(module)
if helper is None:
self.fail(
f"{module.__name__}._debug_enabled is missing — "
"make test_helper_exists_on_every_module pass first"
)
for env_value, expected in CASES:
with self.subTest(module=module.__name__, env=env_value):
env = {} if env_value is None else {"CM_DEBUG": env_value}
with mock.patch.dict(os.environ, env, clear=True):
self.assertEqual(
helper(),
expected,
f"{module.__name__}._debug_enabled() should be "
f"{expected!r} for CM_DEBUG={env_value!r}",
)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 3: Run the test to verify it fails**
Run from repo root:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled -v
```
Expected: import error or `AttributeError: module 'app.cm_web_view' has no attribute '_debug_enabled'`. The failure mode may be that importing `app.cm_web_view` itself fails because Flask is needed — that is fine, see Task 2 for the venv prerequisite.
- [ ] **Step 4: Install runtime deps into the venv if Flask import fails**
Only if the previous step failed at import time with `ModuleNotFoundError: No module named 'flask'`, run:
```bash
/home/yiekheng/projects/cm_bot_v2/.venv/bin/pip install -r /home/yiekheng/projects/cm_bot_v2/requirements.txt
```
Then re-run Step 3. Expected after install: the test fails with the missing `_debug_enabled` attribute, not an import error.
- [ ] **Step 5: Commit the failing test**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/__init__.py tests/test_debug_enabled.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "test: add CM_DEBUG helper parity test (failing)"
```
---
## Task 2: Implement `_debug_enabled` in `cm_web_view.py` and flip `app.run`
**Files:**
- Modify: `app/cm_web_view.py` (top of file — verify `import os` already present at line 10; bottom of file — `__main__` block currently at lines 744-748)
- [ ] **Step 1: Verify `os` is already imported**
Run:
```bash
grep -n "^import os" /home/yiekheng/projects/cm_bot_v2/app/cm_web_view.py
```
Expected: `10:import os`. If absent, add `import os` to the imports section. Do not add `from os import getenv``os.getenv` is what the helper uses.
- [ ] **Step 2: Add the helper near the top of the module**
Insert after the imports section (before `app = Flask(...)`). Find the first blank line after the imports and insert:
```python
def _debug_enabled() -> bool:
"""Return True iff CM_DEBUG env var is set to a truthy value.
Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed).
Anything else, including unset, is False. Default-off so the
Werkzeug debugger is never reachable in production containers.
"""
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
```
- [ ] **Step 3: Replace `debug=True` in the `__main__` block**
Find:
```python
if __name__ == '__main__':
print("Starting CM Web View...")
print("Web interface will be available at: http://localhost:8000")
print("Make sure the API server is running on port 3000")
app.run(host='0.0.0.0', port=8000, debug=True)
```
Replace the last line with:
```python
app.run(host='0.0.0.0', port=8000, debug=_debug_enabled())
```
- [ ] **Step 4: Run the test — `cm_web_view` cases should now pass, `cm_api` cases should still fail**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled -v
```
Expected: `test_helper_exists_on_every_module` and `test_parses_cm_debug_consistently` still fail because `app.cm_api._debug_enabled` doesn't exist yet. The `app.cm_web_view` subTest entries should pass. Confirm by reading the verbose subtest output.
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add app/cm_web_view.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(web): make Werkzeug debug opt-in via CM_DEBUG"
```
---
## Task 3: Implement `_debug_enabled` in `cm_api.py` and switch `run()` to env-resolved default
**Files:**
- Modify: `app/cm_api.py` (top of file — currently no `import os`; class `CM_API.run` method around line 160)
- [ ] **Step 1: Add `import os` at the top**
Find the existing import block (currently lines 1-4):
```python
import threading
from flask import Flask, jsonify, request
from flask_cors import CORS
from .db import DB
```
Insert `import os` as the very first line so the imports remain stdlib-then-third-party:
```python
import os
import threading
from flask import Flask, jsonify, request
from flask_cors import CORS
from .db import DB
```
- [ ] **Step 2: Add the helper above the `CM_API` class**
Insert between the imports and `class CM_API:` (around line 6):
```python
def _debug_enabled() -> bool:
"""Return True iff CM_DEBUG env var is set to a truthy value.
Truthy: '1', 'true', 'yes' (case-insensitive, whitespace-trimmed).
Anything else, including unset, is False. Default-off so the
Werkzeug debugger is never reachable in production containers.
"""
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
```
This text is identical to the helper in `cm_web_view.py` — the parametrized test enforces it stays that way.
- [ ] **Step 3: Change the `run` method signature and resolve when unset**
Find the existing method (currently around line 160):
```python
def run(self, port=3000, debug=True):
# Test database connection before starting server
test_db = self._get_database_connection()
if test_db is None:
print("Cannot start server: Database not available")
exit(1)
self._close_database_connection(test_db)
print(f'CM Bot DB API Listening at Port : {port}')
self.app.run(host='0.0.0.0', port=port, debug=debug)
```
Replace with:
```python
def run(self, port=3000, debug=None):
if debug is None:
debug = _debug_enabled()
# Test database connection before starting server
test_db = self._get_database_connection()
if test_db is None:
print("Cannot start server: Database not available")
exit(1)
self._close_database_connection(test_db)
print(f'CM Bot DB API Listening at Port : {port}')
self.app.run(host='0.0.0.0', port=port, debug=debug)
```
Do **not** touch `run_in_thread` — its `debug=False` default is already safe and used internally.
- [ ] **Step 4: Run the test — both modules should now pass**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled -v
```
Expected: `OK` with all subTests passing for both `app.cm_web_view` and `app.cm_api`.
- [ ] **Step 5: Confirm no caller passes `debug` positionally to `run()`**
```bash
grep -rn "\.run(" /home/yiekheng/projects/cm_bot_v2/app/ | grep -v "self\.app\.run\|app\.run("
```
Expected: only `api.run(port = 3000)` in `cm_api.py:191`. If anything else appears that passes a positional second arg to `CM_API.run`, change it to a keyword argument before continuing — the signature change went from `debug=True` to `debug=None`, so a caller passing `True` positionally would now incorrectly enable debug.
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add app/cm_api.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(api): make Werkzeug debug opt-in via CM_DEBUG"
```
---
## Task 4: Plumb `CM_DEBUG` into `docker-compose.yml`
**Files:**
- Modify: `docker-compose.yml` (the `api-server` `environment:` block at lines 40-49 and the `web-view` `environment:` block at lines 63-66)
- [ ] **Step 1: Add `CM_DEBUG` to the `api-server` environment**
Find:
```yaml
environment:
PYTHONUNBUFFERED: "1"
DB_HOST: ${DB_HOST}
```
(under `api-server:`, currently lines 40-42). Insert `CM_DEBUG` directly after `PYTHONUNBUFFERED`:
```yaml
environment:
PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
DB_HOST: ${DB_HOST}
```
The `${CM_DEBUG:-false}` form guarantees the variable is defined inside the container even if the operator did not set it in their `.env`.
- [ ] **Step 2: Add `CM_DEBUG` to the `web-view` environment**
Find (under `web-view:`, currently lines 63-66):
```yaml
environment:
PYTHONUNBUFFERED: "1"
API_BASE_URL: http://api-server:3000
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
```
Insert `CM_DEBUG` after `PYTHONUNBUFFERED`:
```yaml
environment:
PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
API_BASE_URL: http://api-server:3000
CM_PREFIX_PATTERN: ${CM_PREFIX_PATTERN}
```
Do **not** add it to `telegram-bot` or `transfer-bot` — neither runs a Flask server.
- [ ] **Step 3: Validate the compose file parses**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
docker compose -f docker-compose.yml config >/dev/null && echo OK
```
Expected: `OK`. If the command errors with a YAML or interpolation problem, fix the indentation around the new lines.
- [ ] **Step 4: Confirm the variable reaches both services in the rendered config**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
docker compose -f docker-compose.yml config | grep -E "CM_DEBUG"
```
Expected: two matching lines, one each under `api-server` and `web-view`, value rendered as `false` (or whatever `CM_DEBUG` resolves to in the current shell env).
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "chore(compose): pass CM_DEBUG into api-server and web-view"
```
---
## Task 5: Document `CM_DEBUG` in `.env.example`
**Files:**
- Modify: `.env.example` (currently 32 lines; the existing `=== Deployment Identity ===` section is at the top)
- [ ] **Step 1: Insert a new `Runtime` section near the top**
Find the first three lines of `.env.example`:
```
# === Deployment Identity ===
# Unique name prefix for containers and network (avoid conflicts on same host)
CM_DEPLOY_NAME=rex-cm
```
Add a new section above them:
```
# === Runtime ===
# Set to true ONLY in local dev. Werkzeug debugger = RCE if exposed.
CM_DEBUG=false
# === Deployment Identity ===
# Unique name prefix for containers and network (avoid conflicts on same host)
CM_DEPLOY_NAME=rex-cm
```
- [ ] **Step 2: Confirm the file still parses as a valid env file**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -E "^[A-Z_]+=" .env.example | wc -l
```
Expected: one more line than before the change (run before/after to verify if curious; the absolute count is implementation-dependent).
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add .env.example && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(env): document CM_DEBUG in .env.example"
```
---
## Task 6: Add a one-line note to `AGENTS.md`
**Files:**
- Modify: `AGENTS.md` (the `## Security & Configuration Tips` section at the bottom)
- [ ] **Step 1: Append a bullet to the Security section**
Find the existing section (currently the last block in the file):
```
## Security & Configuration Tips
- Never commit real secrets in `.env`.
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use.
- Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.
```
Add a new bullet above the `cm_bot_hal.py` line:
```
## Security & Configuration Tips
- Never commit real secrets in `.env`.
- `CM_DEBUG` defaults to `false` for both Flask services. Set it to `true` only in local development; rex/siong production env files must leave it unset (the Werkzeug debugger is RCE if reachable).
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use.
- Keep container clocks mounted (`/etc/timezone`, `/etc/localtime`) as compose currently defines to avoid schedule drift.
```
- [ ] **Step 2: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): note CM_DEBUG default and intent"
```
---
## Task 7: Integration verification (manual run)
This task corresponds to the four verification scenarios in the spec. No commit — these are smoke checks. If any fails, do not declare done; debug and fix.
**Files:** none modified.
- [ ] **Step 1: Local dev with `CM_DEBUG=true` — debug ON path**
In a scratch repo-root `.env` (or temporarily setting in shell), set:
```
CM_DEBUG=true
```
Then bring up the local stack:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
docker compose -f docker-compose.yml -f docker-compose.override.yml up --build -d api-server web-view
```
Inspect logs:
```bash
docker compose logs --no-color api-server web-view | grep -E "Debug mode|Debugger PIN"
```
Expected: at least one `* Debug mode: on` line and at least one `Debugger PIN:` line.
Tear down:
```bash
docker compose down
```
- [ ] **Step 2: Local dev with `CM_DEBUG=false` (default) — debug OFF path**
Unset or set `CM_DEBUG=false` in `.env`, then:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
docker compose -f docker-compose.yml -f docker-compose.override.yml up --build -d api-server web-view
```
Inspect logs:
```bash
docker compose logs --no-color api-server web-view | grep -E "Debug mode|Debugger PIN" || echo "no debug lines (expected)"
```
Expected: prints `no debug lines (expected)` because neither pattern matches.
Sanity-check both endpoints still serve:
```bash
curl -s -o /dev/null -w "api %{http_code}\n" http://localhost:3000/acc/ ; \
curl -s -o /dev/null -w "web %{http_code}\n" "http://localhost:${CM_WEB_HOST_PORT:-8001}/api/acc/"
```
Expected: `api 200` and `web 200` (assuming the database is reachable; if the api shows 5xx because of DB, that is unrelated to this change — the absence of debug lines above is the success criterion for this task).
Tear down:
```bash
docker compose down
```
- [ ] **Step 3: Override path regression check**
From the repo root:
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import os
os.environ['CM_DEBUG'] = 'false'
from app.cm_api import _debug_enabled
print('helper says:', _debug_enabled())
import inspect
sig = inspect.signature(__import__('app.cm_api', fromlist=['CM_API']).CM_API.run)
print('run() signature:', sig)
"
```
Expected output:
```
helper says: False
run() signature: (self, port=3000, debug=None)
```
- [ ] **Step 4: Re-run the full unit test suite**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled -v
```
Expected: `OK` with all subTests passing.
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| `_debug_enabled()` helper definition | Task 2 (web), Task 3 (api) |
| `cm_web_view.py:748` debug flag swap | Task 2 |
| `cm_api.py:160` signature change to `debug=None` | Task 3 |
| `import os` added to `cm_api.py` | Task 3, Step 1 |
| `run_in_thread` left untouched | Task 3, Step 3 (explicit "Do not touch") |
| `docker-compose.yml` plumbing for both Flask services | Task 4 |
| `.env.example` Runtime section | Task 5 |
| `AGENTS.md` one-line note | Task 6 |
| Verification: debug-on local | Task 7, Step 1 |
| Verification: debug-off local + endpoints serve | Task 7, Step 2 |
| Verification: explicit override path | Task 7, Step 3 |
| Regression test for helper parity | Task 1 (write), Tasks 2 & 3 (make pass), Task 7 Step 4 (re-run) |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,805 @@
# Prod Hardening C1+C5+C6 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Flask dev server in prod containers with gunicorn (C1), drop `api-server`'s host port (C5), fix the `set_security_pin_api` bool/dict contract bug + clear stale AGENTS.md note (C6), and ship a hand-over guide for the operator-side aaPanel hardening (C3/C4/C7 + dev vhost).
**Architecture:** Add a `create_app()` factory to `cm_api.py` so gunicorn can load `app.cm_api:create_app()`; `cm_web_view.py` already exposes a module-level `app` and needs no factory. Dockerfile `CMD`s swap to gunicorn; `docker-compose.override.yml` (dev) gets `command:` overrides that fall back to `python -m app.cm_X` so Flask's debugger stays available locally. Base `docker-compose.yml` drops api-server's host port; the override re-adds `127.0.0.1:3000:3000` for dev. HAL `set_security_pin_api` returns `{"f_username", "t_username"}`; the existing `cm_telegram.py` consumer becomes correct, and `bot_cli.cmd_set_pin` simplifies. Operator pastes the new `docs/aapanel-hardening.md` snippets into aaPanel.
**Tech Stack:** Python 3.9 (containers) / 3.12 (local venv), Flask 2.3.3, gunicorn 23.0.0 (new dep), Docker Compose v2, `unittest` + `unittest.mock` (stdlib). No other new dependencies.
**Spec:** [docs/superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md](../specs/2026-05-02-prod-hardening-c1-c5-c6-design.md)
---
## File Map
| File | Operation | Purpose |
|---|---|---|
| `requirements.txt` | Modify | Append `gunicorn==23.0.0`. |
| `app/cm_api.py` | Modify | Add module-level `def create_app()` factory. |
| `app/cm_bot_hal.py` | Modify | `set_security_pin_api` returns `{"f_username", "t_username"}` instead of bool. |
| `app/bot_cli.py` | Modify | `cmd_set_pin` reads names from the HAL's return dict; drop the local `get_whatsapp_link_username` call and the dead falsy-return check. |
| `tests/test_bot_cli.py` | Modify | Update `CmdSetPinTests` to expect dict return; remove `test_falsy_set_security_pin_result_exits_nonzero` (now-unreachable path). |
| `docker/api/Dockerfile` | Modify | `CMD` swaps to `gunicorn ... app.cm_api:create_app()`. |
| `docker/web/Dockerfile` | Modify | `CMD` swaps to `gunicorn ... app.cm_web_view:app`. |
| `docker-compose.yml` | Modify | Remove `ports: - "3000"` from `api-server`. |
| `docker-compose.override.yml` | Modify | Add `command: python -m app.cm_api` to api-server (preserves Flask dev server in dev); add `command: python -m app.cm_web_view` to web-view; add `ports: - "127.0.0.1:3000:3000"` to api-server. |
| `AGENTS.md` | Modify | Remove the stale "cm_bot_hal.py contains hardcoded credentials" line. |
| `docs/aapanel-hardening.md` | Create | Operator guide: C3 basic auth, C4 rate-limit + scanner deflection, C7 host firewall, dev vhost for `heng.04080616.xyz`. |
The `app.cm_api:create_app()` factory pattern is what gunicorn needs to load the WSGI app while `cm_api.py`'s class-based bootstrap remains intact. `cm_web_view.py` doesn't need this because `app = Flask(...)` is already module-level.
---
## Task 1: Add gunicorn to `requirements.txt`
**Files:**
- Modify: `requirements.txt`
- [ ] **Step 1: Append gunicorn**
Find the existing requirements (current 7 lines) and append:
```
gunicorn==23.0.0
```
The full file becomes:
```
Flask==2.3.3
mysql-connector-python==8.1.0
flask-cors==4.0.0
python-telegram-bot==22.4
requests==2.32.5
beautifulsoup4==4.13.5
tqdm==4.67.1
gunicorn==23.0.0
```
- [ ] **Step 2: Verify the pin is reachable on PyPI**
```bash
.venv/bin/pip download --no-deps --dest /tmp gunicorn==23.0.0 >/dev/null && echo "OK: gunicorn 23.0.0 available"
```
Expected: `OK: gunicorn 23.0.0 available`. (We don't install into the venv here — unit tests don't import gunicorn. The Docker build pip-installs it from `requirements.txt`.)
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add requirements.txt && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "build: add gunicorn 23.0.0 to requirements"
```
---
## Task 2: Add `create_app()` factory to `app/cm_api.py`
**Files:**
- Modify: `tests/test_bot_cli.py` (one new test class)
- Modify: `app/cm_api.py`
- [ ] **Step 1: Write the failing test**
Append to `tests/test_bot_cli.py` (right before the `if __name__ == "__main__":` block):
```python
class CreateAppFactoryTests(unittest.TestCase):
"""The gunicorn entrypoint loads `app.cm_api:create_app()`. The factory
must exist as a module-level callable that returns the Flask app
object — not the CM_API wrapper class."""
def test_create_app_returns_flask_instance(self):
from flask import Flask
from app.cm_api import create_app
wsgi = create_app()
self.assertIsInstance(wsgi, Flask)
def test_create_app_registers_acc_route(self):
from app.cm_api import create_app
wsgi = create_app()
rules = {r.rule for r in wsgi.url_map.iter_rules()}
# The /acc/ endpoint is registered in CM_API._register_routes.
self.assertIn("/acc/", rules)
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CreateAppFactoryTests -v 2>&1 | tail -10
```
Expected: `ImportError: cannot import name 'create_app' from 'app.cm_api'`.
- [ ] **Step 3: Add the factory**
In `app/cm_api.py`, add the function immediately above `if __name__ == '__main__':` (currently around line 189):
```python
def create_app():
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
Returns the Flask app object so gunicorn can serve it. The
surrounding CM_API class still owns route registration and DB
connection management — this just hands gunicorn the underlying
Flask instance.
"""
return CM_API().app
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CreateAppFactoryTests -v 2>&1 | tail -8
```
Expected: 2 tests, `OK`.
- [ ] **Step 5: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/cm_api.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(api): add create_app factory for gunicorn entrypoint"
```
---
## Task 3: HAL contract fix — `set_security_pin_api` returns dict
**Files:**
- Modify: `tests/test_bot_cli.py` (`CmdSetPinTests`)
- Modify: `app/cm_bot_hal.py`
- Modify: `app/bot_cli.py` (`cmd_set_pin`)
- [ ] **Step 1: Update the failing tests**
In `tests/test_bot_cli.py`, replace the body of `CmdSetPinTests` with the new expectations. Find the existing class:
```python
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_extracts_names_locally_and_succeeds(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t_user_42", "f_user_42")
mock_hal.set_security_pin_api.return_value = True
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_falsy_set_security_pin_result_exits_nonzero(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.get_whatsapp_link_username.return_value = ("t", "f")
mock_hal.set_security_pin_api.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
self.assertEqual(cm.exception.code, 1)
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
```
Replace it with:
```python
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_names_from_hal_return_dict(self, mock_hal_class):
# set_security_pin_api now returns a dict on success and raises
# on any failure path. cmd_set_pin reads names directly from the
# dict instead of pre-fetching them via get_whatsapp_link_username.
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.set_security_pin_api.return_value = {
"f_username": "f_user_42",
"t_username": "t_user_42",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
# The local get_whatsapp_link_username call from the old workaround
# is gone — the HAL resolves names internally.
mock_hal.get_whatsapp_link_username.assert_not_called()
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
```
The previously-existing `test_falsy_set_security_pin_result_exits_nonzero` is intentionally removed: every failure path inside `set_security_pin_api` raises rather than returning a falsy value, so the dead branch and its test go away.
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli.CmdSetPinTests -v 2>&1 | tail -15
```
Expected: `test_prints_names_from_hal_return_dict` fails because `set_security_pin_api` still returns `True` (per the mock default in the existing implementation chain) and `cmd_set_pin` still does `t_username, f_username = bot.get_whatsapp_link_username(args.link)` from the old workaround.
- [ ] **Step 3: Update `set_security_pin_api` to return a dict**
In `app/cm_bot_hal.py`, find the existing method (around line 152):
```python
def set_security_pin_api(self, whatsapp_link: str):
t_username, f_username = self.get_whatsapp_link_username(whatsapp_link)
password = self.get_user_pass_from_acc(f_username)
cm_bot = CM_BOT()
if cm_bot.login(
username = f_username,
password = password
) == False:
raise Exception(f'[Fail login] {f_username} cannot login.')
if cm_bot.set_security_pin(self.security_pin) == False:
cm_bot.logout()
raise Exception(f'Agent acc: {f_username} already has security pin!')
cm_bot.logout()
result = self.update_user_status_to_done(f_username)
if result == False:
raise Exception('Failed to update user status to done')
result = self.insert_user_to_table_user(
{
'f_username': f_username,
'f_password': password,
't_username': t_username,
't_password': self.security_pin
}
)
if result == False:
raise Exception('Failed to insert user to table user')
return result
```
Change the final `return result` line to return a dict:
```python
if result == False:
raise Exception('Failed to insert user to table user')
return {"f_username": f_username, "t_username": t_username}
```
(Only the last line changes. The `result == False` checks above are correct as-is — they validate the inner DB write succeeded and raise on failure.)
- [ ] **Step 4: Simplify `cmd_set_pin` in `app/bot_cli.py`**
Find the current implementation:
```python
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
t_username, f_username = bot.get_whatsapp_link_username(args.link)
success = bot.set_security_pin_api(args.link)
if not success:
print("ERROR: set_security_pin_api returned a falsy result", file=sys.stderr)
sys.exit(1)
print(f"OK: f_username={f_username} t_username={t_username}")
```
Replace with:
```python
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
result = bot.set_security_pin_api(args.link)
print(f"OK: f_username={result['f_username']} t_username={result['t_username']}")
```
The pre-fetch of names is gone (HAL resolves them) and the dead falsy-return check is gone (HAL raises on every failure path; exceptions propagate naturally).
- [ ] **Step 5: Run tests to verify they pass**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_bot_cli -v 2>&1 | tail -8
```
Expected: 28 tests, `OK`. (One fewer than the previous 29 because we removed `test_falsy_set_security_pin_result_exits_nonzero`. New `test_prints_names_from_hal_return_dict` replaces it net-net.)
Wait — recount: Task 2 added 2 tests (`CreateAppFactoryTests`), Task 3 removes 1 and replaces 1 (no net change for CmdSetPinTests). Previous baseline was 27 tests. After Task 2: 29. After Task 3: 28. Both numbers are fine; what matters is `OK`.
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_bot_cli.py app/cm_bot_hal.py app/bot_cli.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "fix(hal): set_security_pin_api returns dict; cm_telegram now correct"
```
The commit message points out a side effect: `cm_telegram.py:87` already does `result['f_username']` against this return value and was a latent TypeError; this fix makes it correct.
---
## Task 4: Swap Dockerfile CMDs to gunicorn
**Files:**
- Modify: `docker/api/Dockerfile`
- Modify: `docker/web/Dockerfile`
- [ ] **Step 1: Update the api Dockerfile**
In `docker/api/Dockerfile`, find the last line:
```
CMD ["python", "-m", "app.cm_api"]
```
Replace with:
```
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:3000", "app.cm_api:create_app()"]
```
Two workers and a 30-second timeout match the spec. The `app.cm_api:create_app()` form (with parens) tells gunicorn to call the factory and use its return value.
- [ ] **Step 2: Update the web Dockerfile**
In `docker/web/Dockerfile`, find the last line:
```
CMD ["python", "-m", "app.cm_web_view"]
```
Replace with:
```
CMD ["gunicorn", "--workers", "2", "--timeout", "30", "--bind", "0.0.0.0:8000", "app.cm_web_view:app"]
```
No factory needed for the web view — `app` is a module-level Flask instance.
- [ ] **Step 3: Sanity-check the Dockerfiles parse**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -E "^(CMD|FROM|EXPOSE|RUN|COPY|ENV|WORKDIR)" docker/api/Dockerfile docker/web/Dockerfile | head -20
```
Expected: ten or so lines including `CMD ["gunicorn", ...]` for each. Each Dockerfile should still have its `FROM python:3.9-slim`, `WORKDIR /app`, `RUN pip install`, `COPY app ./app`, `EXPOSE`, and the new `CMD`.
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker/api/Dockerfile docker/web/Dockerfile && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(docker): swap Flask dev server for gunicorn in api and web images"
```
---
## Task 5: Add `command:` and `ports:` overrides in `docker-compose.override.yml`
**Files:**
- Modify: `docker-compose.override.yml`
The override needs `command:` for api-server and web-view (so dev keeps the Flask debugger via `python -m app.cm_X`) plus a localhost-bound `ports:` for api-server (so dev `curl http://localhost:3000/...` keeps working).
- [ ] **Step 1: Update `api-server` in the override**
Find the existing `api-server:` block (lines 8-15):
```yaml
api-server:
build:
context: .
dockerfile: docker/api/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}"
depends_on:
mysql:
condition: service_healthy
```
Replace with:
```yaml
api-server:
build:
context: .
dockerfile: docker/api/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-api:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_api"]
ports:
- "127.0.0.1:3000:3000"
depends_on:
mysql:
condition: service_healthy
```
The `command:` override replaces the gunicorn `CMD` from the Dockerfile when the override is in play, so dev still runs the Flask dev server (which honors `CM_DEBUG`). The `ports:` re-adds host access on localhost only.
- [ ] **Step 2: Update `web-view` in the override**
Find the existing `web-view:` block:
```yaml
web-view:
build:
context: .
dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
```
Replace with:
```yaml
web-view:
build:
context: .
dockerfile: docker/web/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web:${DOCKER_IMAGE_TAG:-dev}"
command: ["python", "-m", "app.cm_web_view"]
```
- [ ] **Step 3: Validate the override file**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.override.yml') as f:
cfg = yaml.safe_load(f)
api = cfg['services']['api-server']
web = cfg['services']['web-view']
assert api.get('command') == ['python', '-m', 'app.cm_api'], f'api command wrong: {api.get(\"command\")}'
assert api.get('ports') == ['127.0.0.1:3000:3000'], f'api ports wrong: {api.get(\"ports\")}'
assert web.get('command') == ['python', '-m', 'app.cm_web_view'], f'web command wrong: {web.get(\"command\")}'
# Sanity: build/image survive the merge.
assert api['build']['dockerfile'] == 'docker/api/Dockerfile'
assert web['build']['dockerfile'] == 'docker/web/Dockerfile'
# Sanity: the existing healthcheck wiring survives.
assert api['depends_on']['mysql']['condition'] == 'service_healthy'
print('override structure OK')
"
```
Expected: `override structure OK`.
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.override.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(compose): keep Flask dev server in dev override; expose api-server on localhost"
```
---
## Task 6: Drop `api-server` host port from base `docker-compose.yml`
**Files:**
- Modify: `docker-compose.yml`
- [ ] **Step 1: Remove the api-server `ports:` block**
Find the existing `api-server:` block in `docker-compose.yml` (around lines 33-54). The current `ports:` directive is:
```yaml
ports:
- "3000"
```
Delete those two lines. The `api-server:` block becomes (relevant excerpt):
```yaml
# API Server Service
api-server:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-api:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-api-server
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
CM_DEBUG: ${CM_DEBUG:-false}
DB_HOST: ${DB_HOST}
...
```
`api-server` keeps everything else (image, container_name, environment, networks, etc.) — only the `ports:` block goes away.
- [ ] **Step 2: Validate the base file still parses and has no host port for api-server**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.yml') as f:
cfg = yaml.safe_load(f)
api = cfg['services']['api-server']
assert 'ports' not in api, f'api-server should have no host ports in base, got: {api.get(\"ports\")}'
# Web-view still has its host port for aaPanel reach.
web = cfg['services']['web-view']
assert web['ports'] == ['${CM_WEB_HOST_PORT:-8001}:8000'], f'web ports unexpected: {web[\"ports\"]}'
print('base config: api-server has no host port; web-view binding preserved')
"
```
Expected: `base config: api-server has no host port; web-view binding preserved`.
- [ ] **Step 3: Verify dev still gets api-server on localhost (override wins)**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
import yaml
with open('docker-compose.override.yml') as f:
over = yaml.safe_load(f)
assert over['services']['api-server']['ports'] == ['127.0.0.1:3000:3000']
print('dev override: api-server reachable on 127.0.0.1:3000')
"
```
Expected: `dev override: api-server reachable on 127.0.0.1:3000`.
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docker-compose.yml && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "chore(compose): drop api-server host port from base (internal only)"
```
---
## Task 7: Remove the stale `cm_bot_hal.py` line in `AGENTS.md`
**Files:**
- Modify: `AGENTS.md`
- [ ] **Step 1: Remove the line**
Find this line in the `## Security & Configuration Tips` section (currently around line 94):
```
- `app/cm_bot_hal.py` currently contains hardcoded agent credentials/pin; move these to env vars before production use.
```
Delete it. The surrounding bullets stay; only this one goes away because commit `45303d0` already moved those values to env vars (`_get_required_env('CM_AGENT_ID')` etc.).
- [ ] **Step 2: Confirm it's gone**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -n "hardcoded" AGENTS.md && echo "FOUND — delete it" || echo "OK: no stale 'hardcoded' line"
```
Expected: `OK: no stale 'hardcoded' line`.
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add AGENTS.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs(agents): drop stale 'hardcoded credentials' note (moved to env in 45303d0)"
```
---
## Task 8: Create the aaPanel hardening guide
**Files:**
- Create: `docs/aapanel-hardening.md`
- [ ] **Step 1: Write the guide**
Create `docs/aapanel-hardening.md` by lifting the appendix content from `docs/superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md` (everything from `## Appendix: aaPanel hardening guide ...` to the end of the file). Adjust as follows when copying:
1. Drop the `## Appendix: ...` header line.
2. Replace it with the new top-level title `# aaPanel Hardening Guide (Operator)` plus a one-line intro stating this is the hand-over for C3/C4/C7 and pointing back to the spec at `superpowers/specs/2026-05-02-prod-hardening-c1-c5-c6-design.md`.
3. Promote the appendix's `### Threat model recap`, `### C3 — ...`, `### C4 — ...`, `### Dev vhost — ...`, `### C7 — ...`, `### Verification after applying ...` from `###` to `##` so they're top-level sections in the new file (since the appendix wrapper is gone).
The content stays identical — only the section levels and the title change. Doing this by copy-paste rather than re-typing keeps the snippets, IP placeholders, and verification commands byte-identical to what the spec already approved.
The new file's outline (after the lift-and-promote described above) should be:
```
# aaPanel Hardening Guide (Operator)
<one-line intro pointing to the spec>
## Threat model
## C3 — Basic auth on the rex/siong/dev vhosts
### Phone UX note (kept as a sub-heading inside C3)
## C4 — Rate limit + scanner deflection
### Scanner deflection (444 on known probe paths)
### Rate limit (per source IP)
## Dev vhost — `heng.04080616.xyz` → dev PC
## C7 — Host firewall on each Flask host
## Verification (after all blocks applied)
```
- [ ] **Step 2: Verify the file was created and has the expected sections**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
test -f docs/aapanel-hardening.md && \
grep -E "^## " docs/aapanel-hardening.md
```
Expected: lists six sections — Threat model, C3, C4, Dev vhost, C7, Verification.
- [ ] **Step 3: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add docs/aapanel-hardening.md && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "docs: add aaPanel hardening guide (C3/C4/C7 + dev vhost)"
```
---
## Task 9: Integration verification (deferred — needs deploy host)
This task corresponds to the verification scenarios in the spec. No commits — these are smoke checks. If any fails, debug before declaring done.
**Files:** none modified.
**Prerequisites:** docker compose v2 plugin installed; access to a deploy/dev host with the dev stack running.
- [ ] **Step 1: Full unit test suite**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v 2>&1 | tail -10
```
Expected: `OK`. Combined: 2 debug-mode tests + ~28 bot_cli tests including the new `CreateAppFactoryTests`.
- [ ] **Step 2: Dev stack still uses Flask dev server**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
bash scripts/dev.sh up
sleep 10
sudo docker logs dev-cm-web-view 2>&1 | grep -E "Running on|Debug mode|Listening at"
```
Expected: `* Running on http://0.0.0.0:8000`, plus `* Debug mode: on/off` based on your `CM_DEBUG`. **NOT** a `[INFO] Starting gunicorn` line — the override's `command:` keeps the Flask dev server in dev.
- [ ] **Step 3: Prod smoke — gunicorn actually runs**
Two-phase: build with the override (where the `build:` directives live), then bring up with the base only (no `command:` override → Dockerfile CMD wins).
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml down
# Rebuild --no-cache so the new Dockerfile CMD is in the image.
sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache api-server web-view
# Run prod-style: base file only, no command/ports overrides.
sudo docker compose -f docker-compose.yml up -d api-server web-view
sleep 8
sudo docker logs $(sudo docker ps -q -f name=cm-web-view) 2>&1 | grep -E "Listening at|Starting gunicorn|WARNING"
```
Expected: `[INFO] Starting gunicorn 23.0.0`, `[INFO] Listening at: http://0.0.0.0:8000`. **NOT** `WARNING: This is a development server`, **NOT** `Debugger PIN:`. Same shape for api-server. Tear down: `sudo docker compose -f docker-compose.yml down`.
**Pitfall — why two phases:** `docker compose -f docker-compose.yml up --build` alone won't rebuild because the base file has no `build:` directives (services use registry images). The override is what ties the local Dockerfiles to the image tag, so the build step needs the override. Once built, the base file's `image:` reference resolves to the same local image tag, so `up -f docker-compose.yml` finds it.
- [ ] **Step 4: api-server no longer has a host port in prod compose**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
sudo docker compose -f docker-compose.yml ps --format json 2>/dev/null | head -20
sudo docker ps --filter "name=cm-api-server" --format "{{.Names}}\t{{.Ports}}"
```
Expected: api-server's `Ports` column shows `3000/tcp` (internal only, no `0.0.0.0:`). web-view's column still shows the LAN binding `0.0.0.0:8001->8000/tcp` (or whatever `CM_WEB_HOST_PORT` is set to).
- [ ] **Step 5: HAL contract round-trip**
In a Python REPL on a host where the venv has the dependencies and the DB is reachable (typically the dev PC after `dev.sh up`):
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=cm DB_PASSWORD=devpassword DB_NAME=cm \
DB_CONNECTION_TIMEOUT=8 DB_CONNECT_RETRIES=5 DB_CONNECT_RETRY_DELAY=2 \
CM_PREFIX_PATTERN=13c CM_AGENT_ID=x CM_AGENT_PASSWORD=y CM_SECURITY_PIN=000000 \
CM_BOT_BASE_URL=https://example.invalid \
.venv/bin/python -c "
import inspect
from app.cm_bot_hal import CM_BOT_HAL
src = inspect.getsource(CM_BOT_HAL.set_security_pin_api)
assert 'return {\"f_username\": f_username, \"t_username\": t_username}' in src, \
'set_security_pin_api should return a dict, not bool'
print('HAL contract OK: set_security_pin_api returns a dict')
"
```
Expected: `HAL contract OK: set_security_pin_api returns a dict`. (We don't actually call cm99.net here — we just inspect the source to confirm the new return shape is in place.)
- [ ] **Step 6: aaPanel guide rendered**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
ls -la docs/aapanel-hardening.md && \
grep -c "^## " docs/aapanel-hardening.md
```
Expected: file exists, six `##` section headers.
- [ ] **Step 7: Stale doc gone**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -n "hardcoded" AGENTS.md && echo "STILL THERE" || echo "OK: stale note removed"
```
Expected: `OK: stale note removed`.
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| `gunicorn==23.0.0` in requirements | Task 1 |
| `create_app()` factory in `cm_api.py` for gunicorn | Task 2 |
| `set_security_pin_api` returns dict; `cm_telegram.py:87` becomes correct | Task 3 |
| `bot_cli.cmd_set_pin` simplified to use HAL dict | Task 3 |
| `CmdSetPinTests` updated; dead falsy-return test removed | Task 3 |
| `docker/api/Dockerfile` `CMD` → gunicorn factory | Task 4 |
| `docker/web/Dockerfile` `CMD` → gunicorn module-level app | Task 4 |
| Dev override keeps Flask dev server (`command:` overrides) | Task 5 |
| Dev override exposes api-server on `127.0.0.1:3000` | Task 5 |
| Base compose drops api-server's host port | Task 6 |
| AGENTS.md stale "hardcoded credentials" line removed | Task 7 |
| `docs/aapanel-hardening.md` operator guide (C3/C4/C7 + dev vhost + phone UX note) | Task 8 |
| Verification: unit tests, dev still Flask, prod gunicorn, no api host port, HAL contract, doc rendered | Task 9 |
No gaps. No placeholders. Function and method names consistent across tasks (`create_app`, `set_security_pin_api`, `cmd_set_pin`, `_debug_enabled` left untouched).

View File

@ -0,0 +1,639 @@
# R3: Scraper Resilience Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the bare `soup.find(...)['value']` pattern in `app/cm_bot.py` with a helper that raises a typed `ScraperError` and dumps the failing HTML to `logs/scraper-failures/` for postmortem.
**Architecture:** Add `ScraperError`, `_dump_html`, and `_find_input_value` to the `CM_BOT` class; convert five existing call sites that use the `<input name="X" value="...">` pattern; extend `get_register_link` and `get_user_credit` failure paths to dump HTML. Tests live in a new `tests/test_cm_bot_scraper.py`.
**Tech Stack:** Python 3.9 (containers) / 3.12 (local venv), `unittest` + `unittest.mock` (stdlib), `BeautifulSoup` (existing dep). No new dependencies.
**Spec:** [docs/superpowers/specs/2026-05-02-r3-scraper-resilience-design.md](../specs/2026-05-02-r3-scraper-resilience-design.md)
---
## File Map
| File | Operation | Purpose |
|---|---|---|
| `tests/test_cm_bot_scraper.py` | Create | Unit tests for `ScraperError`, `_dump_html`, `_find_input_value`. |
| `app/cm_bot.py` | Modify | Add `ScraperError`, helpers; convert five `'token'` extractions; extend `get_register_link` and `get_user_credit`. |
The helpers are added to the `CM_BOT` class so they have access to `self` for consistency with the existing class-based methods, even though `_dump_html` and `_find_input_value` don't actually need any instance state. Sticking to instance methods keeps the API uniform with everything else in `CM_BOT`.
---
## Task 1: Add `ScraperError`, `_dump_html`, `_find_input_value` (TDD)
**Files:**
- Create: `tests/test_cm_bot_scraper.py`
- Modify: `app/cm_bot.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_cm_bot_scraper.py`:
```python
"""Tests for the cm_bot scraper resilience helpers.
The CM_BOT class currently uses bare `soup.find(...)['value']` calls
that throw cryptic TypeErrors when cm99.net returns an unexpected
response. R3 introduces three pieces:
- ScraperError: typed exception so callers can distinguish scraper
failures from network errors.
- _dump_html(context, content): writes the failing response to
logs/scraper-failures/<context>-<ts>.html and returns the path.
- _find_input_value(soup, name, *, context, raw): the dominant
extraction pattern. Returns the value on success, dumps + raises
ScraperError on miss.
These tests do NOT exercise the live cm99.net integration. They use
small inline HTML fixtures and patch filesystem side effects so the
tests stay hermetic.
"""
import io
import os
import shutil
import tempfile
import unittest
from unittest import mock
from bs4 import BeautifulSoup
from app.cm_bot import CM_BOT, ScraperError
# CM_BOT.__init__ reads CM_BOT_BASE_URL from the env (raises otherwise).
# Set a placeholder so the class is instantiable in tests; nothing here
# actually touches the network.
@mock.patch.dict(os.environ, {"CM_BOT_BASE_URL": "https://example.invalid"})
class ScraperHelpersTests(unittest.TestCase):
def setUp(self):
# Each test gets a fresh tmpdir so the dump helper writes
# somewhere predictable. We chdir into it for the duration of
# the test because _dump_html writes to a relative
# logs/scraper-failures path.
self._old_cwd = os.getcwd()
self._tmp = tempfile.mkdtemp(prefix="r3-test-")
os.chdir(self._tmp)
self.bot = CM_BOT()
def tearDown(self):
os.chdir(self._old_cwd)
shutil.rmtree(self._tmp, ignore_errors=True)
# ---- _dump_html ----
def test_dump_html_creates_dir_and_writes_bytes(self):
path = self.bot._dump_html("ctx-test", b"<html>hi</html>")
self.assertTrue(os.path.isfile(path), f"file should exist: {path}")
with open(path, "rb") as f:
self.assertEqual(f.read(), b"<html>hi</html>")
# The directory was created.
self.assertTrue(path.startswith(os.path.join("logs", "scraper-failures")))
def test_dump_html_accepts_str_content(self):
path = self.bot._dump_html("ctx-test", "<html>hi</html>")
with open(path, "rb") as f:
self.assertEqual(f.read(), b"<html>hi</html>")
def test_dump_html_includes_context_and_timestamp_in_filename(self):
path = self.bot._dump_html("register_form_token", b"x")
basename = os.path.basename(path)
self.assertTrue(basename.startswith("register_form_token-"), basename)
self.assertTrue(basename.endswith(".html"), basename)
# ---- _find_input_value ----
def test_find_input_value_returns_value_when_present(self):
html = '<form><input name="token" value="abc123"></form>'
soup = BeautifulSoup(html, "html.parser")
result = self.bot._find_input_value(
soup, "token", context="happy_path", raw=html.encode()
)
self.assertEqual(result, "abc123")
def test_find_input_value_raises_and_dumps_when_missing(self):
html = '<form><input name="other" value="x"></form>'
soup = BeautifulSoup(html, "html.parser")
with self.assertRaises(ScraperError) as cm:
self.bot._find_input_value(
soup, "token", context="missing_input", raw=html.encode()
)
msg = str(cm.exception)
self.assertIn("missing_input", msg)
self.assertIn("token", msg)
# The path mentioned in the message must actually exist.
# The path appears in parentheses at the end: "(response saved to <path>)"
# We check by listing the dump dir.
dumped = os.listdir(os.path.join("logs", "scraper-failures"))
self.assertEqual(len(dumped), 1, f"expected one dump, got {dumped}")
self.assertTrue(dumped[0].startswith("missing_input-"))
def test_find_input_value_raises_when_input_has_no_value_attr(self):
html = '<form><input name="token"></form>'
soup = BeautifulSoup(html, "html.parser")
with self.assertRaises(ScraperError):
self.bot._find_input_value(
soup, "token", context="no_value_attr", raw=html.encode()
)
def test_find_input_value_does_not_dump_on_success(self):
html = '<form><input name="token" value="abc"></form>'
soup = BeautifulSoup(html, "html.parser")
self.bot._find_input_value(
soup, "token", context="should_not_dump", raw=html.encode()
)
# logs/scraper-failures may not even exist on the happy path.
self.assertFalse(
os.path.isdir(os.path.join("logs", "scraper-failures")),
"happy path should not create the failure dir",
)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_cm_bot_scraper -v 2>&1 | tail -10
```
Expected: `ImportError: cannot import name 'ScraperError' from 'app.cm_bot'` (or similar). The whole class is missing.
- [ ] **Step 3: Add `ScraperError`, `_dump_html`, `_find_input_value` to `app/cm_bot.py`**
In `app/cm_bot.py`, the top of the file currently has:
```python
import datetime
import requests, re
from bs4 import BeautifulSoup
import os
```
Add `ScraperError` immediately after the imports (before `class CM_BOT:`):
```python
class ScraperError(Exception):
"""A cm99.net response did not contain the field we expected.
The raw response is saved to logs/scraper-failures/ before this is
raised; the message identifies which method failed and what was
being looked for.
"""
```
Then add the two helper methods inside `class CM_BOT:`. A natural placement is right after `_setup_headers` and before `get_register_data` (around line 204):
```python
def _dump_html(self, context: str, content) -> str:
"""Save a failing cm99.net response to logs/scraper-failures/.
Returns the path written to so callers can include it in error
messages.
"""
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir = os.path.join("logs", "scraper-failures")
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, f"{context}-{ts}.html")
if isinstance(content, (bytes, bytearray)):
data = bytes(content)
else:
data = str(content).encode("utf-8", "replace")
with open(path, "wb") as f:
f.write(data)
print(f"[scraper-failure] dumped {context} response to {path}")
return path
def _find_input_value(self, soup, name: str, *, context: str, raw) -> str:
"""Extract <input name=NAME value=...>'s value or raise ScraperError.
Saves the raw response to logs/scraper-failures/ before raising
so the operator can postmortem.
"""
el = soup.find("input", {"name": name})
if el is None or "value" not in el.attrs:
path = self._dump_html(context, raw)
raise ScraperError(
f"{context}: input[name={name!r}] missing or has no value attribute "
f"(response saved to {path})"
)
return el["value"]
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_cm_bot_scraper -v 2>&1 | tail -10
```
Expected: 6 tests, `OK`.
- [ ] **Step 5: Confirm prior tests still pass**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper -v 2>&1 | tail -8
```
Expected: combined `OK`. Total: 2 (debug) + 28 (bot_cli) + 6 (scraper) = 36 tests passing.
- [ ] **Step 6: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_cm_bot_scraper.py app/cm_bot.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "feat(scraper): add ScraperError + _dump_html + _find_input_value helpers"
```
---
## Task 2: Convert the five `<input name="token">` extractions to use the helper
**Files:**
- Modify: `app/cm_bot.py` (`get_register_form_token`, `get_security_pin_form_token`, `get_transfer_token`, `transfer_credit` — three lines inside this method)
The dominant pattern in cm_bot.py is `soup.find('input', {'name': 'token'})['value']`. Replacing each call site is mechanical: keep the request, change the extraction.
- [ ] **Step 1: Convert `get_register_form_token`**
Find (around line 344-354):
```python
def get_register_form_token(self):
try:
response = self.session.post(
f'{self.base_url}/cm/loadUserAccount',
headers=self.get_register_form_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value']
except requests.exceptions.RequestException as e:
print(f"Error getting register form: {e}")
return None
```
Replace the `soup.find(...)['value']` line with the helper:
```python
def get_register_form_token(self):
try:
response = self.session.post(
f'{self.base_url}/cm/loadUserAccount',
headers=self.get_register_form_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
return self._find_input_value(
soup, "token",
context="register_form_token",
raw=response.content,
)
except requests.exceptions.RequestException as e:
print(f"Error getting register form: {e}")
return None
```
The `except requests.exceptions.RequestException` only catches network errors. `ScraperError` (which inherits from `Exception`) propagates up to whatever `cm_bot_hal.py` is catching, which is `except Exception as e` — same as before, just with a useful message instead of a TypeError.
- [ ] **Step 2: Convert `get_security_pin_form_token`**
Find (around line 357-360):
```python
def get_security_pin_form_token(self):
response = self.session.get(f'{self.base_url}/cm/setSecurityPin')
soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value']
```
Replace with:
```python
def get_security_pin_form_token(self):
response = self.session.get(f'{self.base_url}/cm/setSecurityPin')
soup = BeautifulSoup(response.content, 'html.parser')
return self._find_input_value(
soup, "token",
context="security_pin_form_token",
raw=response.content,
)
```
- [ ] **Step 3: Convert `get_transfer_token`**
Find (around line 463-466):
```python
def get_transfer_token(self):
response = self.session.get(f'{self.base_url}/cm/transfer')
soup = BeautifulSoup(response.content, 'html.parser')
return soup.find('input', {'name' : "token"})['value']
```
Replace with:
```python
def get_transfer_token(self):
response = self.session.get(f'{self.base_url}/cm/transfer')
soup = BeautifulSoup(response.content, 'html.parser')
return self._find_input_value(
soup, "token",
context="transfer_token",
raw=response.content,
)
```
- [ ] **Step 4: Convert the three extractions inside `transfer_credit`**
Find (around line 426-446):
```python
def transfer_credit(self, t_username: str, t_password: str, amount: float):
token = self.get_transfer_token()
transfer_search_data = self.get_transfer_search_data(token, t_username)
response = self.session.post(
f'{self.base_url}/cm/searchTransferUser',
data=transfer_search_data,
headers=self.transfer_search_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
name = soup.find('input', {'id': "name"})['value']
token = soup.find('input', {'name': "token"})['value']
toUserId = soup.find('input', {'id': "toUserId"})['value']
```
This block uses two different finders: `{'name': X}` for `token`, and `{'id': X}` for `name` and `toUserId`. The `_find_input_value` helper as written only handles `{'name': X}`. We have two options:
**Option A — extend the helper.** Add an optional `by` parameter (`'name'` or `'id'`).
**Option B — keep `_find_input_value` narrow, write inline checks for the `id`-based ones.**
We pick Option A. It's a one-parameter widening with a default of `"name"`, so existing call sites are unchanged.
In `app/cm_bot.py`, update the helper signature:
```python
def _find_input_value(self, soup, ident: str, *, context: str, raw, by: str = "name") -> str:
"""Extract <input {by}=IDENT value=...>'s value or raise ScraperError.
`by` selects between matching <input name=...> (default) and
<input id=...>. Saves the raw response to logs/scraper-failures/
before raising so the operator can postmortem.
"""
el = soup.find("input", {by: ident})
if el is None or "value" not in el.attrs:
path = self._dump_html(context, raw)
raise ScraperError(
f"{context}: input[{by}={ident!r}] missing or has no value attribute "
f"(response saved to {path})"
)
return el["value"]
```
Update the test for the existing happy-path — the `name` parameter is now called `ident`. Also add a test for the `by="id"` path. Append to `tests/test_cm_bot_scraper.py` inside `ScraperHelpersTests`:
```python
def test_find_input_value_supports_by_id(self):
html = '<form><input id="toUserId" value="42"></form>'
soup = BeautifulSoup(html, "html.parser")
result = self.bot._find_input_value(
soup, "toUserId", context="by_id", raw=html.encode(), by="id",
)
self.assertEqual(result, "42")
```
The five existing test methods that use `name="token"` keep working because the rename `name → ident` is a positional argument; tests pass it positionally.
Now replace the body of `transfer_credit`:
```python
def transfer_credit(self, t_username: str, t_password: str, amount: float):
token = self.get_transfer_token()
transfer_search_data = self.get_transfer_search_data(token, t_username)
response = self.session.post(
f'{self.base_url}/cm/searchTransferUser',
data=transfer_search_data,
headers=self.transfer_search_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
name = self._find_input_value(
soup, "name", context="transfer_search_name", raw=response.content, by="id",
)
token = self._find_input_value(
soup, "token", context="transfer_search_token", raw=response.content,
)
toUserId = self._find_input_value(
soup, "toUserId", context="transfer_search_toUserId", raw=response.content, by="id",
)
transfer_data = self.get_transfer_data(token, t_username, name, toUserId, amount, t_password)
response = self.session.post(
f'{self.base_url}/cm/saveTransfer',
data=transfer_data,
headers=self.transfer_credit_headers
)
return True if re.search(r'Successfully saved the record\.', response.text) else False
```
The rest of `transfer_credit` (the second POST and the success-string check) stays identical. The commented-out `# with open('transfer_credit.html', ...)` block at the end can be deleted as part of this edit (the dump now happens automatically on a parse miss).
- [ ] **Step 5: Run tests to verify everything still passes**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_cm_bot_scraper -v 2>&1 | tail -10
```
Expected: 7 tests, `OK` (six original + one new for `by="id"`).
- [ ] **Step 6: Confirm full suite still green**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper -v 2>&1 | tail -8
```
Expected: total 37 tests, `OK`.
- [ ] **Step 7: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add tests/test_cm_bot_scraper.py app/cm_bot.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "refactor(scraper): convert input-value extractions to helper"
```
---
## Task 3: Make `get_register_link` and `get_user_credit` failure paths informative
**Files:**
- Modify: `app/cm_bot.py` (`get_register_link`, `get_user_credit`)
These two methods don't fit the input-value helper. `get_register_link` extracts an `<a href="...">` from a specific form; `get_user_credit` does multi-step text-content navigation through a table. We add explicit dump+raise / dump+log behavior at each.
- [ ] **Step 1: Update `get_register_link`**
Find (around line 402-406):
```python
def get_register_link(self):
response = self.session.get(f"{self.base_url}/cm/showQrCode")
soup = BeautifulSoup(response.content, 'html.parser')
soup = soup.find('form', {'id': 'qrCodeForm'})
return soup.find('a')['href']
```
Replace with:
```python
def get_register_link(self):
response = self.session.get(f"{self.base_url}/cm/showQrCode")
soup = BeautifulSoup(response.content, 'html.parser')
form = soup.find('form', {'id': 'qrCodeForm'})
if form is None:
path = self._dump_html("register_link_form", response.content)
raise ScraperError(
f"register_link: form#qrCodeForm not found "
f"(response saved to {path})"
)
anchor = form.find('a')
if anchor is None or 'href' not in anchor.attrs:
path = self._dump_html("register_link_anchor", response.content)
raise ScraperError(
f"register_link: <a href> inside form#qrCodeForm not found "
f"(response saved to {path})"
)
return anchor['href']
```
- [ ] **Step 2: Update `get_user_credit`'s except block**
Find (around line 448-461):
```python
def get_user_credit(self):
response = self.session.post(
f'{self.base_url}/cm/userProfile',
headers=self.get_user_credit_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
try:
return round(float(soup.find('table', {'class': 'generalContent'}).find(text=re.compile('Credit Available')).parent.parent.find_all('td')[2].text.replace(",","")), 2)
except:
print(f"Error getting credit.")
now = datetime.datetime.now().strftime('%Y%m%d_%H%M')
# with open(f'credit-{now}.html', 'wb') as f:
# f.write(response.content)
return 0
```
Replace the `except:` block so it actively dumps the HTML (uncomment the previously-commented dump and route it through the helper):
```python
def get_user_credit(self):
response = self.session.post(
f'{self.base_url}/cm/userProfile',
headers=self.get_user_credit_headers
)
soup = BeautifulSoup(response.content, 'html.parser')
try:
return round(float(soup.find('table', {'class': 'generalContent'}).find(text=re.compile('Credit Available')).parent.parent.find_all('td')[2].text.replace(",","")), 2)
except Exception as exc:
self._dump_html("get_user_credit", response.content)
print(f"Error getting credit: {exc}")
return 0
```
Three changes inside the `except`: catch `Exception as exc` (was bare `except`), call `_dump_html` (was a commented-out `with open(...)`), drop the now-unused `now = datetime.datetime.now()...` line. The bare-except → `Exception as exc` widening is intentional — the original bare except also caught `KeyboardInterrupt` and `SystemExit`, which we should not be swallowing in a credit-read.
The function still returns `0` on failure to preserve the existing contract (callers in `cm_bot_hal.py:transfer_credit_api` check `amount <= 0.01` and short-circuit). We do not change that.
- [ ] **Step 3: Run all tests**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper -v 2>&1 | tail -8
```
Expected: 37 tests, `OK`. (No new tests in this task — the changed methods are integration-level and would need live cm99.net or HTML fixtures to exercise. The two methods' happy paths are unchanged; their failure paths are dump+raise/log, which is independently exercised by Task 1's helper tests.)
- [ ] **Step 4: Commit**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
git add app/cm_bot.py && \
git -c user.name='yiekheng' -c user.email='yiekheng@04080616.xyz' \
commit -m "refactor(scraper): make get_register_link and get_user_credit dump on failure"
```
---
## Task 4: Final verification
**Files:** none modified.
- [ ] **Step 1: All tests green**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper -v 2>&1 | tail -8
```
Expected: 37 tests, `OK`.
- [ ] **Step 2: Sanity-grep for the old pattern**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
grep -n "soup.find('input'.*\['value'\]" app/cm_bot.py && echo "STILL THERE" || echo "OK: no bare input-value extractions"
```
Expected: `OK: no bare input-value extractions`.
- [ ] **Step 3: ScraperError is exported from `app.cm_bot`**
```bash
cd /home/yiekheng/projects/cm_bot_v2 && \
.venv/bin/python -c "
from app.cm_bot import CM_BOT, ScraperError
assert issubclass(ScraperError, Exception)
assert hasattr(CM_BOT, '_dump_html')
assert hasattr(CM_BOT, '_find_input_value')
print('ScraperError + helpers OK')
"
```
Expected: `ScraperError + helpers OK`.
- [ ] **Step 4: Real-call smoke (deferred to operator)**
Trigger an actual bot operation against cm99.net (e.g., from the dev tier with real agent creds: `bash scripts/bot_cli.sh credit <username> <password>`). On success: behavior unchanged. On a parse failure that previously would have TypeError'd: a `ScraperError` propagates with a clear message and a file appears under `logs/scraper-failures/<context>-<timestamp>.html`.
---
## Spec Coverage Check (self-review)
| Spec requirement | Task |
|---|---|
| `ScraperError` class | Task 1 |
| `_dump_html` instance method | Task 1 |
| `_find_input_value` instance method, default `by="name"` | Task 1 |
| `_find_input_value` extension to support `by="id"` for `transfer_credit` | Task 2 |
| Convert `get_register_form_token` | Task 2 step 1 |
| Convert `get_security_pin_form_token` | Task 2 step 2 |
| Convert `get_transfer_token` | Task 2 step 3 |
| Convert three extractions inside `transfer_credit` (`name`, `token`, `toUserId`) | Task 2 step 4 |
| `get_register_link` failure path dumps + raises | Task 3 step 1 |
| `get_user_credit` failure path dumps + logs (returns 0 unchanged) | Task 3 step 2 |
| Unit tests in `tests/test_cm_bot_scraper.py` | Task 1 + Task 2 |
| `logs/` already gitignored, no .gitignore change | (existing — verified pre-flight) |
| No CSRF token caching | (intentionally not in plan) |
No gaps. No placeholders. `ScraperError`, `_dump_html`, `_find_input_value` names consistent across tasks. `by` parameter introduced in Task 2 with a default that preserves Task 1's API contract.

View File

@ -0,0 +1,310 @@
# B1: Next.js Scaffold + Side-by-Side Deploy + Proxy Parity Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Sequel to:** [2026-05-02-prod-hardening-c1-c5-c6-design.md](2026-05-02-prod-hardening-c1-c5-c6-design.md)
**Followed by:** B2 (UI port), B3 (PWA), B4 (cutover — delete `app/cm_web_view.py`, rename `cm-web-next``cm-web`).
## Problem
`app/cm_web_view.py` is a 758-line Flask app with inline HTML/CSS/JS. The user wants to migrate the UI to Next.js for a modern stack (App Router, React, Tailwind, PWA-ready). The full migration is multi-cycle; this cycle (B1) only ships the **scaffold and deploy story** — empty UI, parity-only proxies — so we can verify the build/deploy pipeline before pouring effort into the UI port (B2). The existing Flask UI keeps working in parallel during B2/B3.
## Goal
Stand up a new `cm-web-next` Docker service running a Next.js 15 app at `web/` in this repo, exposed on host port `${CM_WEB_NEXT_HOST_PORT:-8010}` (default 8010 — non-conflicting with 8000/dev, 8001/rex, 8005/siong). The app:
1. Renders a single placeholder page so the operator can confirm the service is up.
2. Does **not** expose any public `/api/*` route. Browser-side data access in B2 will go through React Server Components (server-side fetch from `api-server:3000` inside the compose network) and Server Actions (mutations) — no scrapable JSON endpoint. See "No public /api/* route" below.
3. Builds reproducibly through `scripts/publish.sh` like the four existing services.
4. Comes up automatically with `bash scripts/dev.sh up`.
The existing `cm-web` (Flask) service is untouched. Both services run side-by-side in dev and prod until B4 cuts over.
## Non-Goals
- Any actual UI work. The page reads `<h1>cm-web-next scaffold</h1>` plus a one-line confirmation that the API proxies are reachable. Tabs, tables, inline editing, sort, refresh — all in B2.
- PWA / `manifest.webmanifest` / service worker / icons — all in B3.
- Auth — handled by aaPanel (C3 basic auth covers the new vhost too).
- Tests in B1. The scaffold is mostly config and glue; integration smoke is sufficient. B2 will introduce vitest when there's actual UI logic to test.
- Migrating `app/cm_web_view.py` or removing the existing `cm-web` Flask service. B4's job after the UI is fully ported.
- Renaming the `cm-web-next` service to `cm-web` during this cycle. The rename is part of B4 along with the Flask retirement.
## Architecture
### Repo layout
```
cm_bot_v2/
├── app/ ← Python services (unchanged)
├── docker/
│ ├── api/, web/, telegram/, transfer/ ← Python service Dockerfiles
│ └── web-next/Dockerfile ← NEW
├── web/ ← NEW: full Next.js 15 project root
│ ├── package.json
│ ├── package-lock.json
│ ├── next.config.ts
│ ├── tsconfig.json
│ ├── tailwind.config.ts
│ ├── postcss.config.mjs
│ ├── .gitignore
│ ├── .dockerignore
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── globals.css
│ │ └── api/
│ │ └── [...path]/
│ │ └── route.ts ← Catch-all proxy to api-server
│ └── public/ ← (empty for B1; B3 adds icons)
├── docker-compose.yml ← + cm-web-next service
├── docker-compose.override.yml ← + cm-web-next build directive
├── envs/<name>/.env.example ← + CM_WEB_NEXT_HOST_PORT
└── scripts/
├── dev.sh ← MODIFIED: include cm-web-next in up/logs
└── publish.sh ← MODIFIED: + cm-web-next image
```
`web/` is at the repo root (per L3 location decision), parallel to `app/`. It is its own JavaScript/TypeScript project with its own `package.json`, completely independent of the Python tree.
### Tech stack
| Choice | Value | Rationale |
|---|---|---|
| Next.js | 15.x stable | Modern, App Router, Route Handlers |
| Language | TypeScript | Next.js default; tooling assumes it |
| Styling | Tailwind CSS v4 | What `create-next-app` ships in 2026 |
| Routing | App Router (`app/` dir) | Modern; B2/B3 conventions cleaner |
| Build output | `output: 'standalone'` in `next.config.ts` | Slim runtime image (~150MB vs 1GB+) |
| Package manager | npm | Zero ceremony, default tooling |
| Node | 22 LTS (in container) | Current LTS line |
| Trailing slashes | `trailingSlash: true` | Parity with Flask `@app.route('/api/acc/')` paths |
### No public `/api/*` route — RSC + Server Actions architecture
The Flask `cm_web_view.py` exposed four JSON proxy endpoints to the browser (`/api/acc/`, `/api/user/`, `/api/update-acc-data`, `/api/update-user-data`). The Next.js rewrite **does not** replicate them as public routes. There's no third-party API consumer (the bot CLI talks to `api-server` directly inside the compose network, not through the web), so a public JSON surface is pure attack surface with no upside.
Instead:
- **Reads (B2)** happen inside React Server Components. `app/page.tsx` and friends are server-rendered; their server-side `fetch("http://api-server:3000/acc/")` calls run on the Next.js server inside the docker network. The browser only ever receives the rendered HTML / RSC payload — no JSON endpoint to call.
- **Writes (B2)** go through Next.js Server Actions: `"use server"` async functions called from React components. Next.js auto-handles the wire format (POST to `/<page>` with magic encoded payload, processed server-side and routed by the framework). The browser never sees a `/api/*` path.
- **api-server stays internal** as designed in C5 (no host port published). `web-next` reaches it as `api-server:3000` via the compose network, same as `web-view` does today.
Net result: zero public JSON endpoints, zero scrapable paths beyond `/` and Server Actions' opaque internal routes. The hash-encoded URL scheme that earlier drafts proposed is gone — there's nothing to obfuscate when nothing is public.
For B1 specifically there is no Route Handler, no `web/app/api/` directory, no `web/lib/api-paths.ts`, and no smoke-test API call. The placeholder page documents the architecture so the next reader understands the choice.
### Empty UI page
`web/app/page.tsx` renders a placeholder confirming the service is up. The implementation plan invokes the `frontend-design` skill to generate `page.tsx` and `layout.tsx` (per user direction — frontend-design owns all web design code in this codebase). The brief handed to the skill:
- **Page purpose:** scaffold-confirmation page for `cm-web-next`. Shipped only in B1; replaced by the real dashboard in B2.
- **Required content:** product name (`CM Bot V2`), the literal text `cm-web-next scaffold` so an operator can grep the page for it, a one-line "B2 lands the real UI" note, and an obvious link to `/api/acc/` so a smoke test of the proxy is one click away.
- **Design constraints:** uses Tailwind v4 (already configured); no external font/image deps; no JS interactivity (Server Component is fine); single page, no nav. Should clearly read as a temporary scaffold (not a real dashboard) so nobody mistakes it for production UI.
- **Out of scope:** dark mode, responsive breakpoints beyond mobile-first defaults, animations.
`web/app/layout.tsx` is also generated by frontend-design — minimal `<html lang="en">` shell with `<body>` Tailwind defaults and tab title `CM Bot V2`.
`web/app/globals.css` is the Tailwind v4 import (`@import "tailwindcss";`) — written by hand, no design surface.
### Dockerfile
Multi-stage build at `docker/web-next/Dockerfile`, modeled after Next.js's official Dockerfile example. Uses `output: 'standalone'` so the runtime image only carries the standalone server bundle (~150MB total).
```dockerfile
# syntax=docker/dockerfile:1.7
# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY web/package.json web/package-lock.json* ./
RUN npm ci
# --- build ---
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/ ./
RUN npm run build
# --- runtime ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
```
The standalone server listens on `$PORT`. We expose 3000 inside the container and bind to a host port via compose (default 8010 → container 3000). Naming note: container port 3000 is internal; nothing else at this scale uses 3000 because api-server's host port was dropped in C5.
### `next.config.ts`
```ts
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
trailingSlash: true,
};
export default config;
```
### Compose changes
**`docker-compose.yml`** — add `cm-web-next` service near the existing `web-view`:
```yaml
# Next.js Web View (side-by-side with web-view during B-cycle migration).
web-next:
image: "${CM_IMAGE_PREFIX:-your-registry/namespace}/cm-web-next:${DOCKER_IMAGE_TAG:-latest}"
container_name: ${CM_DEPLOY_NAME:-cm}-web-next
restart: unless-stopped
ports:
- "${CM_WEB_NEXT_HOST_PORT:-8010}:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
API_BASE_URL: http://api-server:3000
networks:
- bot-network
depends_on:
- api-server
```
**`docker-compose.override.yml`** — add a `web-next` block with build directive (parallel to existing services):
```yaml
web-next:
build:
context: .
dockerfile: docker/web-next/Dockerfile
image: "${CM_IMAGE_PREFIX:-local}/cm-web-next:${DOCKER_IMAGE_TAG:-dev}"
# No `command:` override — Next.js standalone server is the dev runtime
# for B1. (B2 may add a `command: ["npm", "run", "dev"]` to enable
# hot-reload; not in this cycle.)
```
The compose service name is `web-next` (not `cm-web-next`) for parity with the existing pattern (`web-view`, `api-server`, `telegram-bot`, `transfer-bot`). Container name resolves to `${CM_DEPLOY_NAME}-web-next` via the existing pattern.
### Env file changes
Add `CM_WEB_NEXT_HOST_PORT` to all three `.env.example` templates, with deployment-appropriate defaults:
| File | New line |
|---|---|
| `envs/dev/.env.example` | `CM_WEB_NEXT_HOST_PORT=8010` |
| `envs/rex/.env.example` | `CM_WEB_NEXT_HOST_PORT=8011` |
| `envs/siong/.env.example` | `CM_WEB_NEXT_HOST_PORT=8012` |
The committed templates document the convention; each operator's gitignored `.env` is updated by hand (or by `cp envs/<name>/.env.example .env` for fresh clones).
### `scripts/dev.sh`
Modify the `up`, `logs`, and `up` invocation in `reset-db` to include `web-next`:
```bash
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
"${COMPOSE[@]}" ps
;;
reset-db)
"${COMPOSE[@]}" down --volumes --remove-orphans
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
;;
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
;;
```
### `scripts/publish.sh`
Append `web-next` to the `SERVICES` array:
```bash
SERVICES=(
"api docker/api/Dockerfile"
"telegram docker/telegram/Dockerfile"
"web docker/web/Dockerfile"
"transfer docker/transfer/Dockerfile"
"web-next docker/web-next/Dockerfile"
)
```
The image name template `${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}` produces `gitea.04080616.xyz/yiekheng/cm-web-next:<tag>`, matching the compose `image:` reference.
### `AGENTS.md` updates
- Add a bullet under "Project Structure & Module Organization" describing `web/` and the side-by-side `cm-web-next` service.
- Add a one-line note under "Dev Tier" pointing to `http://localhost:8010/` for the new UI alongside `http://localhost:8000/` for the legacy Flask UI.
### `web/.gitignore` and `web/.dockerignore`
Standard Next.js ignores. `web/.gitignore` includes `.next/`, `node_modules/`, build/test outputs. `web/.dockerignore` excludes `node_modules/`, `.next/`, `.git/`, and the rest of the repo (we only need `web/` in the build context, but Docker copies what we ask for via the explicit `COPY web/...` line).
## Files Created / Modified
| File | Operation |
|---|---|
| `web/package.json` | Create — Next.js 15 deps, scripts |
| `web/package-lock.json` | Create — generated by `npm install` |
| `web/next.config.ts` | Create — `standalone` + `trailingSlash` |
| `web/tsconfig.json` | Create — `create-next-app` defaults |
| `web/tailwind.config.ts` | Create — Tailwind v4 default |
| `web/postcss.config.mjs` | Create — Tailwind v4 PostCSS plugin |
| `web/app/layout.tsx` | Create — title + Tailwind globals |
| `web/app/page.tsx` | Create — placeholder card |
| `web/app/globals.css` | Create — `@import "tailwindcss";` |
| `web/.gitignore` | Create — Next.js standard |
| `web/.dockerignore` | Create — exclude node_modules / .next |
| `docker/web-next/Dockerfile` | Create — multi-stage Node 22 alpine |
| `docker-compose.yml` | Modify — add `web-next` service |
| `docker-compose.override.yml` | Modify — add `web-next` build directive |
| `envs/dev/.env.example` | Modify — `CM_WEB_NEXT_HOST_PORT=8010` |
| `envs/rex/.env.example` | Modify — `CM_WEB_NEXT_HOST_PORT=8011` |
| `envs/siong/.env.example` | Modify — `CM_WEB_NEXT_HOST_PORT=8012` |
| `scripts/dev.sh` | Modify — include `web-next` in up/logs/reset-db |
| `scripts/publish.sh` | Modify — append `web-next` to `SERVICES` |
| `AGENTS.md` | Modify — note new service + UI URL |
No file removals. Nothing in `app/` is touched.
## Verification
1. **`web/` builds locally without docker.** `cd web && npm install && npm run build` succeeds (smoke for someone editing TS without rebuilding the container each time).
2. **`bash scripts/dev.sh up` brings up five services.** `mysql`, `api-server`, `web-view` (Flask, port 8000), `web-next` (Next.js, port 8010). `docker compose ps` shows all five `running`.
3. **Empty page renders.** `curl -s http://localhost:8010/ | grep -E "cm-web-next scaffold|B1 scaffold"` returns hits. Open in a browser → centered card visible with the link.
4. **Proxy parity.** `curl -s http://localhost:8010/api/acc/ | head -c 200` returns the same JSON shape as `curl -s http://localhost:8000/api/acc/ | head -c 200` (both proxy to `api-server:3000/acc/`).
5. **POST proxy.** `curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"13c1000","password":"x","status":"","link":""}' http://localhost:8010/api/update-acc-data` returns the same response (and same exit status) as the same POST against the Flask `web-view` on port 8000.
6. **Image publishes.** `bash scripts/publish.sh dev` publishes `cm-web-next:dev` alongside the other four images. (Skip in CI; smoke check on the operator's machine when ready.)
7. **Old `cm-web` still works.** `curl -s http://localhost:8000/` still returns the Flask HTML page. Side-by-side preserved.
8. **Prod parity check.** `docker compose -f docker-compose.yml config | grep -E "web-next" | head` shows the new service. `docker compose -f docker-compose.yml config | grep "ports:" -A 1` confirms `web-next` is on `${CM_WEB_NEXT_HOST_PORT:-8010}:3000` and `web-view` is unchanged on `${CM_WEB_HOST_PORT:-8001}:8000`.
## Risk
Low.
- **Side-by-side means double the surface for now.** Two web services running, two host ports, two images. The cost is real but bounded — once B2 is feature-complete and B4 cuts over, the old `cm-web` retires and we're back to one. The alternative (in-place rewrite) was higher risk because broken B2 commits would break prod.
- **No public `/api/*` route — be aware before adding one.** B1 explicitly does not expose JSON endpoints to the browser. If a future need surfaces (a third-party consumer, a mobile native client, a webhook callback), revisit the architecture deliberately rather than adding a Route Handler ad-hoc. The threat model assumed in this design is "internal CRUD only, browser is the only consumer."
- **Trailing slashes.** `trailingSlash: true` in Next.js means `/api/acc` redirects to `/api/acc/` with a 308. The Flask version always required the slash. Behavior is parity-equivalent for clients that include the slash; clients that don't get a redirect they didn't get before. The UI we control will always include it.
- **Build context size.** Docker build context includes the whole repo unless we set up `.dockerignore`. The repo's existing `.dockerignore` excludes `__pycache__`, `*.py[cod]`, `*.log`, `.git`, `logs/`, `node_modules/`. The `node_modules/` entry already prevents copying the host's `web/node_modules/` if the operator ran `npm install` on the host — good. We don't need a new repo-root `.dockerignore` change.
## Frontend-design conventions
All web design code in this codebase (TSX components, page layouts, CSS-in-class Tailwind, etc.) goes through the `frontend-design` skill rather than being hand-written. This decision applies to B1's placeholder, B2's full UI port, and any subsequent UI work. Glue code (Route Handlers, `next.config.ts`, env wiring, the Dockerfile, package.json, etc.) is written directly.
Practical implication for the implementation plan: there is one task that explicitly invokes `frontend-design` with the brief described in the "Empty UI page" section above and writes the returned `page.tsx` and `layout.tsx` into `web/app/`. Subsequent edits to those files in B2 follow the same pattern.
## Out-of-Scope Follow-Ups
- **B2** — port the UI: tabs, two tables, sortable columns, inline cell editing, refresh, stats cards, error states. Implementation invokes `frontend-design`.
- **B3** — PWA: `manifest.webmanifest`, `next-pwa` (or hand-rolled service worker), 192/512 icons, install prompt.
- **B4** — cutover: delete `app/cm_web_view.py`, retire `cm-web` service, rename `cm-web-next``cm-web` (and same for the image), update aaPanel-hardening guide and `dev.sh` accordingly.
- **Hot-reload in dev**`command: ["npm", "run", "dev"]` in the override + bind-mount `web/` into the container. Useful but not necessary for B1; revisit when B2 starts and iteration speed matters.
- **Vitest** — unit tests for components/route handlers. B2 lays the foundation when there's actual logic.

View File

@ -0,0 +1,369 @@
# B2+B3: Next.js UI Port + PWA Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Sequel to:** [2026-05-02-b1-nextjs-scaffold-design.md](2026-05-02-b1-nextjs-scaffold-design.md)
**Followed by:** B4 (cutover — delete `app/cm_web_view.py`, retire `cm-web` Flask service, rename `cm-web-next``cm-web`).
## Problem
B1 stood up the Next.js scaffold with no UI and no public `/api/*`. We now need to port the Flask `cm_web_view.py` UI to Next.js and ship a Progressive Web App so operators can install it on their phones.
The legacy Flask UI:
- Two tabs: **Accounts** (4 columns: username, password, status, link) and **Users** (5 columns: f_username, f_password, t_username, t_password, last_update_time).
- Inline cell editing — click → input → save POST → refresh row.
- Sortable username column with prefix-priority logic (configured prefix on top, then descending).
- Auto-refresh every 30s.
- Stats cards (count of each table).
- Refresh button.
The new Next.js implementation must preserve every operator-facing capability without exposing the four `/api/*` endpoints publicly (per the B1 architecture: RSC for reads, Server Actions for writes).
## Goal
Bundle B2 (UI port) and B3 (PWA) into one cycle that ships a fully functional `cm-web-next` capable of replacing `cm-web` (Flask) entirely. The legacy service remains running in parallel until **B4** cuts over.
## Non-Goals
- **B4 cutover**`app/cm_web_view.py` and the `cm-web` service stay untouched.
- **Service worker / offline mode** — manifest + installability is the PWA scope. An internal CRUD tool that needs the api-server to function offers no value offline; adding a service worker for a "feels native" sake is YAGNI for now.
- **Authentication** — aaPanel C3 (basic auth) handles auth at the proxy. No app-level login flow.
- **New features beyond Flask parity** — no bulk edit, no search, no filtering. Same surface as today, just on a better stack.
- **Tests** — vitest setup is captured as an out-of-scope follow-up. The UI is exercised end-to-end via the dev stack; logic-heavy parts (Server Actions) are simple wrappers around `fetch`.
## Architecture
### Routing
URL-based tabs via App Router parallel routes:
| URL | Server Component | Renders |
|---|---|---|
| `/` | `app/page.tsx` | Accounts table |
| `/users/` | `app/users/page.tsx` | Users table |
Each page is a Server Component that fetches data from `api-server:3000` over the docker network (no client-side fetch, no public JSON). The shared layout (`app/layout.tsx`) renders nav + the active page.
URL-based tabs (vs single-page state-based tabs) means:
- Refresh works (you stay on the right tab).
- Shareable URLs.
- No `'use client'` wrapper needed for the page itself — the page is a Server Component.
- Loading and error states get clean Suspense/error boundaries via `loading.tsx` and `error.tsx`.
### Data flow
```
Browser ─GET─▶ web-next (Next.js) ─server-side fetch─▶ api-server:3000/acc/
▲ │
│ rendered HTML / RSC payload │ JSON
└────────────────────────────────────────────────────────┘
```
Mutations:
```
Browser ─POST (Server Action)─▶ web-next ─fetch─▶ api-server:3000/update-acc-data
revalidatePath('/')
(re-renders with fresh data)
```
The `useOptimistic` hook (React 19) gives instant cell update on save — server action runs in the background; UI rolls back on failure.
### File structure
```
web/
├── app/
│ ├── layout.tsx ← rewrite (frontend-design): nav, theme-color metadata, manifest link
│ ├── globals.css ← unchanged from B1
│ ├── page.tsx ← rewrite (frontend-design): accounts dashboard
│ ├── users/
│ │ └── page.tsx ← new (frontend-design): users dashboard
│ ├── actions.ts ← new (hand-written): updateAcc, updateUser Server Actions
│ ├── error.tsx ← new (frontend-design): top-level error boundary UI
│ ├── icon.tsx ← new (frontend-design): Next.js auto-generates favicon PNG
│ ├── apple-icon.tsx ← new (frontend-design): Next.js auto-generates apple-touch-icon
│ └── manifest.ts ← new (hand-written): PWA manifest config
├── components/
│ ├── accounts-table.tsx ← new (frontend-design): client component, inline editing
│ ├── users-table.tsx ← new (frontend-design): client component, inline editing
│ ├── editable-cell.tsx ← new (frontend-design): generic inline-edit primitive
│ ├── nav.tsx ← new (frontend-design): top nav with two tabs
│ └── auto-refresh.tsx ← new (frontend-design): client component that triggers revalidation every 30s
├── lib/
│ ├── api.ts ← new (hand-written): server-side fetch helpers
│ └── types.ts ← new (hand-written): TypeScript types matching api-server JSON
└── public/ ← unchanged from B1 (.gitkeep only)
```
`web/lib/api-paths.ts` and `web/app/api/` are not introduced — the architecture pivot in B1 stands.
### `frontend-design` brief structure
frontend-design generates **all TSX** in this cycle. The brief that gets handed to it covers (in one invocation, returning many files):
- A coherent visual identity replacing the brutalist construction-tape scaffold. Whatever frontend-design picks must read as a real production dashboard, not a placeholder. The scaffold's job was "obviously temporary"; B2's job is "obviously the real UI" — clear visual distinction so anyone who sees both knows which is which.
- Two-page layout (Accounts and Users) with a shared nav.
- Inline cell editing UX: hover affordance → click → text input → save (button or Enter) / cancel (button or Escape) → optimistic update.
- Sortable column on username with prefix-priority logic baked in. The prefix is read from `process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN` (or hardcoded `13c` if absent) — passed through `lib/api.ts` to the table component as a prop.
- Status badges for `acc.status`: empty/wait/done = three visually distinct states.
- Refresh button (forces revalidation; equivalent to `router.refresh()`).
- Error boundary visual: catches the api-server-unreachable case, shows a clear "API unavailable" message with retry.
- Manifest icon design (`icon.tsx`, `apple-icon.tsx`) — uses Next.js's `ImageResponse` API to render an SVG-style icon to PNG. Should match the dashboard's visual identity, not the scaffold's hazard tape.
- Mobile-first responsive: tables collapse to card stacks below 640px breakpoint.
### Server Actions (`app/actions.ts`)
Hand-written:
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";
export async function updateAccount(data: {
username: string;
password: string;
status: string;
link: string;
}): Promise<{ ok: boolean; error?: string }> {
try {
await fetchApi("/update-acc-data", { method: "POST", body: data });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
export async function updateUser(data: {
f_username: string;
f_password: string;
t_username: string;
t_password: string;
}): Promise<{ ok: boolean; error?: string }> {
try {
await fetchApi("/update-user-data", { method: "POST", body: data });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
```
Each action returns a discriminated `{ ok, error? }` so the client component can show a toast/error indicator without throwing.
### `lib/api.ts` (server-side fetch helper)
```typescript
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
export async function fetchApi(
path: string,
options: { method?: "GET" | "POST"; body?: unknown; cache?: RequestCache } = {},
): Promise<unknown> {
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
method: options.method ?? "GET",
cache: options.cache ?? "no-store",
headers: options.body ? { "content-type": "application/json" } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
};
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
}
return res.json();
}
export async function getAccounts() {
const data = await fetchApi("/acc/");
return data as Acc[];
}
export async function getUsers() {
const data = await fetchApi("/user/");
return data as User[];
}
```
`cache: "no-store"` ensures every request hits api-server fresh — RSC caching would stale the dashboard. Combined with `revalidatePath` on mutations, this gives correct re-render behavior.
### `lib/types.ts`
```typescript
export type Acc = {
username: string;
password: string;
status: string;
link: string;
};
export type User = {
f_username: string;
f_password: string;
t_username: string;
t_password: string;
last_update_time: string | null;
};
```
Mirrors the SQL schema from the dev seed and the api-server's column projection.
### PWA — `app/manifest.ts`
Next.js 15 supports `app/manifest.ts` exporting a function that returns the manifest JSON. Next renders it at `/manifest.webmanifest` automatically.
```typescript
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "CM Bot V2",
short_name: "CM Bot",
description: "CM Bot account and user dashboard",
start_url: "/",
display: "standalone",
orientation: "portrait",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{ src: "/icon", sizes: "any", type: "image/png" },
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
],
};
}
```
The `theme_color` value is finalized by frontend-design (matching the chosen aesthetic) — `#000000` here is a placeholder the implementation will overwrite once the design lands.
### Icons via `app/icon.tsx` and `app/apple-icon.tsx`
Next.js 15 generates `/icon` and `/apple-icon` as PNGs at build time from any TSX component returning JSX (rendered via `next/og`'s `ImageResponse`). frontend-design designs the icon JSX directly — no external SVG-to-PNG tooling, no checked-in PNG binaries, no manual asset pipeline.
Example shape (frontend-design provides the actual JSX):
```typescript
import { ImageResponse } from "next/og";
export const size = { width: 512, height: 512 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(/* JSX returning a styled <div> with brand mark */),
size,
);
}
```
`apple-icon.tsx` follows the same pattern with `size = { width: 180, height: 180 }`.
### Auto-refresh
The legacy Flask UI auto-refreshes every 30s via `setInterval`. We preserve that behavior with a tiny client component:
```typescript
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AutoRefresh({ intervalMs = 30000 }: { intervalMs?: number }) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [router, intervalMs]);
return null;
}
```
`router.refresh()` re-runs the Server Component fetch and patches the result in — no full page reload, no flicker. Mounted in the layout so it covers both pages.
### Error boundary (`app/error.tsx`)
```typescript
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
/* frontend-design owns visuals: clear "API unavailable" message,
prominent retry button, mention of contacting the operator */
);
}
```
Catches every render-time and Server-Action-time error in the route subtree. The most likely failure mode is `api-server` unreachable; the error boundary turns a stack trace into a usable retry screen.
### Compose / Dockerfile / scripts — no changes
`docker-compose.yml`, `docker-compose.override.yml`, `docker/web-next/Dockerfile`, `scripts/dev.sh`, `scripts/publish.sh` are untouched. The build step picks up the new files automatically because `COPY web/ ./` in the Dockerfile copies everything under `web/`.
The `npm install` layer (no lockfile yet) re-resolves, but `package.json` doesn't change in this cycle — no new deps. (Next.js 15's `ImageResponse` for icons is in `next/og`, already shipped with Next.js.)
## Files Created / Modified
| File | Operation | Owner |
|---|---|---|
| `web/app/layout.tsx` | Rewrite | frontend-design |
| `web/app/page.tsx` | Rewrite | frontend-design |
| `web/app/users/page.tsx` | Create | frontend-design |
| `web/app/error.tsx` | Create | frontend-design |
| `web/app/icon.tsx` | Create | frontend-design |
| `web/app/apple-icon.tsx` | Create | frontend-design |
| `web/app/actions.ts` | Create | hand-written |
| `web/app/manifest.ts` | Create | hand-written |
| `web/components/accounts-table.tsx` | Create | frontend-design |
| `web/components/users-table.tsx` | Create | frontend-design |
| `web/components/editable-cell.tsx` | Create | frontend-design |
| `web/components/nav.tsx` | Create | frontend-design |
| `web/components/auto-refresh.tsx` | Create | hand-written |
| `web/lib/api.ts` | Create | hand-written |
| `web/lib/types.ts` | Create | hand-written |
No file deletions. No changes outside `web/`.
## Verification
1. **Build succeeds.** `bash scripts/dev.sh up` brings up the stack; `dev-cm-web-next` starts without webpack errors.
2. **Accounts table renders.** `curl -sf http://localhost:8010/ | grep -E "13c1000|13c1011"` returns hits — the seed accounts from the dev MySQL are visible. (Dev DB has 4 available + 2 'done' accounts seeded by `docker/mysql/init.d/02-seed.sql`.)
3. **Users table renders.** `curl -sf http://localhost:8010/users/ | grep -E "player_one_seed|player_two_seed"` returns hits — the seeded user pairings show up.
4. **Inline edit (acc).** Open `http://localhost:8010/` in a browser. Click an editable cell on row `13c1010`. Change a value. Save. The cell updates instantly (optimistic), then the page revalidates. Verify in mysql: `mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT * FROM acc WHERE username='13c1010'"` shows the new value.
5. **Inline edit (user).** Same flow on `/users/`.
6. **Sort.** Click the username header (or whatever the sortable affordance is). Order flips between ascending and descending; prefix-priority is preserved (rows starting with `13c` stay on top).
7. **Refresh button.** Click it; the data round-trips api-server. Look for the network HTML/RSC re-fetch in browser devtools.
8. **Auto-refresh.** Wait 30 seconds with the page open; observe the network tab fire a fetch automatically.
9. **Error boundary.** With dev stack up, `sudo docker stop dev-cm-api-server`, then refresh `http://localhost:8010/`. The error boundary renders. `sudo docker start dev-cm-api-server`, click retry → page recovers.
10. **PWA install.** On Chrome (desktop or Android), the address bar shows an "Install" affordance. Install. The app opens chromeless. The icon shows the frontend-design icon, not a generic letter glyph.
11. **iOS Add-to-Home.** On Safari iOS, "Add to Home Screen" — the home-screen icon shows the apple-touch-icon (180x180), title is "CM Bot".
12. **Legacy Flask still works.** `curl -sf http://localhost:8000/` returns the Flask HTML page unchanged.
13. **No public API.** `curl -i http://localhost:8010/api/anything/` returns 404 (or 308 → 404, per B1).
## Risk
Medium.
- **Server Action call sites are sprinkled across client components.** A mismatch between the action's argument shape and what the client passes silently fails until you trigger the action. Mitigated by importing the action's TypeScript signature into the client component (compile-time check).
- **`useOptimistic` rollback on error.** If the Server Action returns `{ ok: false }`, the client must reverse the optimistic update. Easy to forget. The `editable-cell.tsx` primitive owns this so each table doesn't have to.
- **`revalidatePath` cache semantics.** Mutating `/api/acc` revalidates `/`; mutating `/api/user` revalidates `/users`. If a future feature crosses that boundary (e.g., editing an acc that's also referenced by a user row), we'd need to revalidate both. Out of scope for now.
- **iOS "Add to Home Screen" doesn't honor `manifest.webmanifest`.** iOS uses `apple-icon.tsx` and the `<title>` from `app/layout.tsx`; the manifest is mostly a hint. The two icon files (`icon.tsx`, `apple-icon.tsx`) cover both Android (manifest-driven) and iOS.
- **First Docker build still slow.** No lockfile means `npm install` re-resolves every cache miss. Acceptable for B2; revisit if iteration speed becomes painful.
- **Auto-refresh + inline edit collision.** If a user is mid-edit when the 30s auto-refresh fires, `router.refresh()` re-renders the table. The editable cell needs to detect "currently editing" and skip the rerender for that cell — handled in `editable-cell.tsx` by anchoring edit state outside the cell's `key` and only refreshing when no cell is in editing mode (or by debouncing the refresh during edits).
## Out-of-Scope Follow-Ups
- **B4 cutover** — separate cycle: delete `app/cm_web_view.py`, retire `cm-web` service, rename `cm-web-next``cm-web` (image, container name, compose service name).
- **Vitest setup + tests** for `lib/api.ts` and the Server Actions. The current verification is end-to-end; component-level tests are valuable but not yet structured.
- **Service worker** for offline fallback. Internal CRUD doesn't need it. If we ever want operators to "see the last-known data" while offline, a Workbox-generated SW is a small lift.
- **Bulk edit / search / filter** in the tables — not in current Flask UI either.
- **i18n / dark mode / theme toggle** — punt until someone asks.
- **Real-time updates via SSE or WebSocket** — current 30s polling is fine for the operator workflow; rebuilding to push-based is its own design problem.

View File

@ -0,0 +1,134 @@
# Debug-Mode Hotfix: Env-Driven `CM_DEBUG`
**Date:** 2026-05-02
**Status:** Approved (design)
**Scope:** Hotfix only. Larger security hardening (real WSGI server, reverse proxy, auth, scanner deflection) is tracked separately under the security-hardening sub-project.
## Problem
Both Flask entrypoints currently start with the Werkzeug debugger enabled:
- `app/cm_web_view.py:748``app.run(host='0.0.0.0', port=8000, debug=True)`
- `app/cm_api.py:160``def run(self, port=3000, debug=True)`, then `self.app.run(host='0.0.0.0', port=port, debug=debug)`
Container logs confirm the debugger is active in deployed containers (`* Debug mode: on`, `Debugger PIN: 702-685-302`). The Werkzeug debugger gives remote code execution to anyone who can reach the port and supply the PIN, and the same containers are receiving public-style scanner probes (`/.env`, `/.git/config`, `/.aws/config`, `/.htpasswd`). This is the highest-priority issue in the codebase right now.
The user wants to keep debug mode available locally (local = dev tier) while ensuring it is off in the rex and siong production deployments.
## Goal
Make debug mode opt-in via the `CM_DEBUG` environment variable. Default off. No other behavior changes.
## Non-Goals
- Switching from `app.run` to a production WSGI server (gunicorn/uvicorn). Belongs to security hardening.
- Adding a reverse proxy, TLS, auth, or rate limiting.
- Changing `app/cm_bot_hal.py` hardcoded credentials.
- Touching `cm_telegram.py` or `cm_transfer_credit.py` — neither runs a Flask server.
- Adding `robots.txt` or scanner deflection.
## Design
### `_debug_enabled()` helper
Both Flask modules add the same small helper. Defined locally in each file (no new shared module — only two call sites, and `app/__init__.py` is currently a near-empty package marker).
```python
def _debug_enabled() -> bool:
return os.getenv("CM_DEBUG", "false").strip().lower() in ("1", "true", "yes")
```
Accepts `1`, `true`, `yes` (case-insensitive, whitespace-trimmed) as truthy. Anything else, including unset, is false. This matches the lenient parsing pattern already used for env-driven config in the recent refactor (commit `45303d0`).
### `app/cm_web_view.py`
Replace the bottom `__main__` block:
```python
if __name__ == '__main__':
print("Starting CM Web View...")
print("Web interface will be available at: http://localhost:8000")
print("Make sure the API server is running on port 3000")
app.run(host='0.0.0.0', port=8000, debug=_debug_enabled())
```
`os` is already imported at the top of the file (line 10) — no new import needed.
### `app/cm_api.py`
Three changes:
0. Add `import os` at the top of the file (currently absent — only `threading`, Flask, and `.db` are imported).
1. Change the `run` signature default so callers can still force-override, but unspecified means "read the env":
```python
def run(self, port=3000, debug=None):
if debug is None:
debug = _debug_enabled()
...
self.app.run(host='0.0.0.0', port=port, debug=debug)
```
2. Leave `run_in_thread(self, port=3000, debug=False)` alone. It is only used internally and its `debug=False` default is already safe; passing `debug=None` would break that contract.
The `__main__` block stays as `api.run(port=3000)` — by passing nothing it now picks up the env-driven default.
### `docker-compose.yml`
Add `CM_DEBUG: ${CM_DEBUG:-false}` to the `environment:` blocks of `api-server` and `web-view` (the only Flask services). The `${CM_DEBUG:-false}` form ensures the variable is *always* defined inside the container, even if the operator forgot to set it in their `.env`. Telegram and transfer services do not need it.
`docker-compose.override.yml` does not need changes — it inherits `environment:` from the base file.
### `.env.example`
Add a new section near the top:
```
# === Runtime ===
# Set to true ONLY in local dev. Werkzeug debugger = RCE if exposed.
CM_DEBUG=false
```
### `envs/rex/.env` and `envs/siong/.env`
These files are intentionally not in git (the directories are committed empty). The operator's existing prod env files do not set `CM_DEBUG`, which makes the default (`false`) apply automatically. No edit needed; the README/AGENTS.md update below documents the convention for any new deployment.
### Documentation
- `AGENTS.md` — add a one-line entry under "Build, Test, and Development Commands" or "Security & Configuration Tips" noting `CM_DEBUG=true` is the local-dev override and **must** stay unset in published env files.
## Files Changed
| File | Change |
|---|---|
| `app/cm_web_view.py` | Add `_debug_enabled()` helper; pass it to `app.run(debug=...)`. |
| `app/cm_api.py` | Add `import os`; add `_debug_enabled()` helper; change `run()` default to `debug=None` and resolve from env when `None`. |
| `docker-compose.yml` | Add `CM_DEBUG: ${CM_DEBUG:-false}` to `api-server` and `web-view` `environment:` blocks. |
| `.env.example` | New `Runtime` section documenting `CM_DEBUG`. |
| `AGENTS.md` | One-line note about `CM_DEBUG`. |
No new dependencies. No version bumps.
## Verification
1. **Local, debug on.** Set `CM_DEBUG=true` in repo-root `.env`, run `bash scripts/local_build.sh`. Web-view log shows `* Debug mode: on` and a `Debugger PIN: ...` line. API log shows the same.
2. **Local, debug off.** Set `CM_DEBUG=false` (or remove the line). Rebuild. Logs show `* Debug mode: off` and **no PIN line**. Hitting `/api/acc/` and `/api/user/` still returns 200 with valid JSON.
3. **Prod parity check.** With `CM_DEBUG` unset in the deploy env (matches rex/siong today), confirm container logs show debug off. Confirm the existing `192.168.0.210` scanner probes for `/.env` and `/.git/config` still 404 with no traceback or debugger response.
4. **Override path.** From a Python REPL inside the api container, calling `CM_API().run(port=3001, debug=True)` still honors the explicit override (regression check on the `debug=None` sentinel).
## Risk
Minimal. The Werkzeug `debug=False` path is the framework default and is what every production Flask deployment uses. The only user-visible behavior loss is the in-browser traceback page and auto-reloader, both of which should never have been on in containers in the first place.
The one edge case worth naming: the existing `cm_api.py:run()` signature lets a caller pass `debug=False` explicitly and still get debug-off behavior; changing the default to `None` preserves that. Nothing in the repo calls `run()` with a positional `debug` argument (verified via grep before implementation), so the signature change is safe.
## Out-of-Scope Follow-Ups (for the security-hardening spec)
Captured here so they aren't forgotten:
- Replace `app.run` with gunicorn (or waitress) in both `cm_api` and `cm_web_view` Dockerfiles.
- Put a reverse proxy (Caddy/Traefik/nginx) in front of `web-view` with TLS, basic auth or token auth, and rate limiting.
- Add `robots.txt` returning `Disallow: /` and a 410/444 default for unknown paths to deflect noisy scanners.
- Audit `app/cm_bot_hal.py` hardcoded credentials/PIN — already flagged in `AGENTS.md` "Security & Configuration Tips".
- Confirm whether `192.168.0.210` is a NAT hop for public traffic (router/firewall question) and decide whether the host port should be bound only to a private interface.

View File

@ -0,0 +1,499 @@
# Local-as-Dev Tier (Sub-Project A) Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Sequel to:** [2026-05-02-debug-mode-hotfix-design.md](2026-05-02-debug-mode-hotfix-design.md)
**Out of scope (separate):** rex/siong env file rotation (R2), cm_bot.py scraper resilience (R3), security hardening sub-project (C), Next.js web view (B).
## Problem
`cm_bot_v2` runs two production deployments — `rex` (port 8001) and `siong` (port 8005) — both deployed via Portainer from images on `gitea.04080616.xyz/yiekheng`. There is no formal local development tier. Local iteration today either touches a real production database or has no DB at all (`web-view` is the only service that boots without one). This blocks safe testing of the security hardening (sub-project C) and the planned Next.js rewrite (B), both of which need to be exercised end-to-end without poking prod data.
## Goal
Add a local-only "dev tier" that spins up `mysql` + `api-server` + `web-view` self-contained on a developer machine, plus a Python CLI that gives the same trigger surface the Telegram bot exposes. The goal is end-to-end iteration on the bot's business logic without launching a real Telegram bot, without touching rex/siong databases, and without making automated calls to cm99.net unless the developer explicitly invokes one.
## Non-Goals
- Importing a sanitized snapshot of rex/siong data into the dev DB. Seed data only; snapshot import can come later.
- Containerizing the new bot CLI. It runs from the local Python virtualenv against a port-published dev mysql.
- Changing the prod compose file (`docker-compose.yml`). Portainer stacks for rex/siong remain untouched.
- Migrating or rotating the existing committed `envs/rex/.env` and `envs/siong/.env` files. Those are an R2 problem.
- Building or running `telegram-bot` or `transfer-bot` automatically in dev. They are gated behind a compose `bots` profile and stay quiet.
- Adding curses/textual/prompt_toolkit. The interactive TUI uses stdlib `input()` only.
- Adding a `remove`/`delete` CLI subcommand. The current Telegram bot has no analog and the schema has no soft-delete state.
## Architecture Overview
Three additions, no removals:
1. **A `mysql` service in `docker-compose.override.yml`** — MySQL 8.0 image, named volume for persistence, init scripts mounted at `/docker-entrypoint-initdb.d/`. Published to `127.0.0.1:3306:3306` so the local CLI (running outside Docker) can reach it without exposing the port to anything else on the network.
2. **A new Python CLI module: `app/bot_cli.py`** — argparse-driven, mirrors the four Telegram bot handlers plus three operational ops. With no arguments, drops into a stdlib interactive menu (TUI-style; not full curses).
3. **Two shell scripts in `scripts/`**`dev.sh` (lifecycle: `up`/`down`/`reset-db`/`logs`/`status`) and `bot_cli.sh` (env-loading wrapper for `python -m app.bot_cli`).
Compose service start matrix:
| Command | mysql | api-server | web-view | telegram-bot | transfer-bot |
|---|---|---|---|---|---|
| `dev.sh up` | ✓ | ✓ | ✓ | (gated by `bots` profile) | (gated by `bots` profile) |
| `bot_cli.sh ...` | (already up — E2 check) | (already up) | (already up) | — (CLI is local Python) | — |
| Prod (Portainer using `docker-compose.yml`) | — | ✓ | ✓ | ✓ | ✓ |
Network: api-server reaches `mysql:3306` over the compose network; the local CLI reaches `127.0.0.1:3306` via the published port. `DB_HOST` is overridden by `bot_cli.sh` so a single `.env` works for both.
## Components
### 1. `docker-compose.override.yml` changes
Add a `mysql` service to the existing override file (which currently only has `build:` directives for local image builds):
```yaml
services:
mysql:
image: mysql:8.0
container_name: ${CM_DEPLOY_NAME:-cm}-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-devroot}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.d:/docker-entrypoint-initdb.d:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-devroot}"]
interval: 5s
timeout: 3s
retries: 12
networks:
- bot-network
api-server:
depends_on:
mysql:
condition: service_healthy
telegram-bot:
profiles: ["bots"]
transfer-bot:
profiles: ["bots"]
volumes:
mysql-data:
name: ${CM_DEPLOY_NAME:-cm}-mysql-data
```
Notes:
- The `127.0.0.1:` host-binding prefix means port 3306 is reachable from your shell only — not from other machines on the LAN. Important: the rex/siong scanner at `192.168.0.210` cannot reach this even if it tried.
- `depends_on: { mysql: { condition: service_healthy } }` means `api-server` waits for the healthcheck to pass before starting. This eliminates the cold-start race where api-server tried to connect before mysql was accepting traffic.
- Adding `profiles: ["bots"]` to `telegram-bot` and `transfer-bot` keeps them out of `docker compose up` in dev, but `docker compose run --rm telegram-bot ...` still works for parity tests.
### 2. `docker/mysql/init.d/`
Two SQL scripts run once on first volume creation (mysql:8 invokes everything in `/docker-entrypoint-initdb.d/` in alphabetical order):
- `docker/mysql/init.d/01-schema.sql` — exactly the DDL from `AGENTS.md` (acc + user tables, utf8mb4). The `CREATE DATABASE` from AGENTS.md is dropped because mysql:8 image creates `${DB_NAME}` via env. The `USE rex_cm` line is replaced with `USE \`${DB_NAME}\``.
- `docker/mysql/init.d/02-seed.sql` — minimal seed: one row in `acc` matching `CM_PREFIX_PATTERN=13c` so `get_next_username` works, plus three filler rows.
Seed contents:
```sql
INSERT INTO acc (username, password, status, link) VALUES
('13c1000', 'seedpass', '', ''),
('13c1001', 'seedpass', '', ''),
('13c1002', 'seedpass', '', ''),
('13c1003', 'seedpass', '', '');
```
These are dev-only seed values — never real credentials. The scripts only run on a fresh volume (mysql refuses to re-init an initialized volume). To re-seed, run `dev.sh reset-db`.
### 3. `app/bot_cli.py`
```python
import argparse
import os
import sys
from .cm_bot_hal import CM_BOT_HAL
def _print_user(user: dict) -> None:
print(f"Username: {user['username']}")
print(f"Password: {user['password']}")
print(f"Link: {user['link']}")
def cmd_register(_args):
bot = CM_BOT_HAL()
_print_user(bot.get_user_api())
def cmd_set_pin(args):
bot = CM_BOT_HAL()
if not bot.is_whatsapp_url(args.link):
print(f"ERROR: not a WhatsApp URL: {args.link}", file=sys.stderr)
sys.exit(2)
# Resolve names locally so we have something useful to print regardless of
# what set_security_pin_api returns. The HAL currently returns a bool from
# the trailing insert_user_to_table_user; the Telegram handler has the
# same shape and a latent bug accessing result['f_username']. We avoid
# depending on the return shape here.
t_username, f_username = bot.get_whatsapp_link_username(args.link)
success = bot.set_security_pin_api(args.link)
if not success:
print("ERROR: set_security_pin_api returned a falsy result", file=sys.stderr)
sys.exit(1)
print(f"OK: f_username={f_username} t_username={t_username}")
def cmd_insert_user(args):
bot = CM_BOT_HAL()
f_password = bot.get_user_pass_from_acc(args.f_username)
if not f_password:
print(f"ERROR: no password for {args.f_username}", file=sys.stderr)
sys.exit(2)
success = bot.insert_user_to_table_user({
'f_username': args.f_username,
'f_password': f_password,
't_username': args.t_username,
't_password': bot.security_pin,
})
if not success:
print("ERROR: insert failed", file=sys.stderr)
sys.exit(1)
print(f"OK: inserted {args.f_username} → {args.t_username}")
def cmd_credit(args):
bot = CM_BOT_HAL()
print(f"Credit: {bot.get_user_credit(args.username, args.password)}")
def cmd_transfer(args):
bot = CM_BOT_HAL()
print(bot.transfer_credit_api(args.f_username, args.f_password, args.t_username, args.t_password))
def cmd_monitor_once(args):
bot = CM_BOT_HAL()
available = bot.get_all_available_acc()
print(f"Available accounts: {len(available)} (target: {args.target})")
if len(available) >= args.target:
print("Already at target; nothing to do.")
return
for _ in range(len(available), args.target):
try:
user = bot.create_new_acc()
print(f"Created: {user['username']}")
except Exception as exc:
print(f"ERROR creating account: {exc}", file=sys.stderr)
sys.exit(1)
def cmd_interactive(_args):
"""Telegram-style menu in a TTY loop. stdlib only."""
print("CM Bot CLI — interactive (type 'q' to quit, '?' for menu)")
while True:
print()
print(" 1 Register / get next account")
print(" 2 <whatsapp_link> Set security PIN")
print(" 3 <f_username> <t_username> Insert into user table")
print(" credit <username> <password> Read account credit")
print(" transfer <fu> <fp> <tu> <tp> One-shot credit transfer")
print(" monitor [N] Run monitor once (default 20)")
print(" q Quit")
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if not line:
continue
if line in ("q", "quit", "exit"):
return
if line in ("?", "help", "menu"):
continue
argv = line.split()
# Map TUI shortcuts to argparse subcommand names so we reuse the same
# dispatch table for both modes.
TUI_ALIASES = {"1": "register", "2": "set-pin", "3": "insert-user"}
argv[0] = TUI_ALIASES.get(argv[0], argv[0])
try:
args = build_parser().parse_args(argv)
args.func(args)
except SystemExit:
# argparse calls sys.exit() on parse error; swallow it to keep the
# REPL alive instead of bailing out of the loop.
continue
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="bot_cli", description="CM Bot dev CLI (mirrors Telegram triggers).")
sub = p.add_subparsers(dest="command")
sp = sub.add_parser("register", aliases=["get-acc"], help="Get next available account (Telegram /1).")
sp.set_defaults(func=cmd_register)
sp = sub.add_parser("set-pin", help="Set security PIN from a WhatsApp link (Telegram /2).")
sp.add_argument("link")
sp.set_defaults(func=cmd_set_pin)
sp = sub.add_parser("insert-user", help="Insert into user table (Telegram /3).")
sp.add_argument("f_username")
sp.add_argument("t_username")
sp.set_defaults(func=cmd_insert_user)
sp = sub.add_parser("credit", help="Read account credit balance.")
sp.add_argument("username")
sp.add_argument("password")
sp.set_defaults(func=cmd_credit)
sp = sub.add_parser("transfer", help="One-shot credit transfer.")
sp.add_argument("f_username")
sp.add_argument("f_password")
sp.add_argument("t_username")
sp.add_argument("t_password")
sp.set_defaults(func=cmd_transfer)
sp = sub.add_parser("monitor-once", aliases=["monitor"], help="One iteration of the auto-create monitor.")
sp.add_argument("--target", type=int, default=20)
sp.set_defaults(func=cmd_monitor_once)
sp = sub.add_parser("interactive", help="Drop into the TUI menu.")
sp.set_defaults(func=cmd_interactive)
return p
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command is None:
# Default mode: interactive TUI.
return cmd_interactive(args) or 0
return args.func(args) or 0
if __name__ == "__main__":
sys.exit(main())
```
Design notes:
- The TUI loop reuses `argparse` parsing so subcommand and interactive paths share validation. SystemExit is caught so a typo (e.g., `2` with no link) prints the argparse error and returns to the prompt instead of killing the REPL.
- The default `python -m app.bot_cli` (no args) drops into interactive mode. This matches the user's "TUI" expectation while keeping the same module usable as a one-shot script.
- Subcommand names are spelled-out verbs (`register`, `set-pin`) for shell scripting; the TUI accepts `1`/`2`/`3` shortcuts so muscle memory from Telegram works.
- The CLI imports `CM_BOT_HAL` directly. No new business logic. If you change the bot's behavior in `cm_bot_hal.py`, the CLI tracks automatically.
### 4. `scripts/dev.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Lifecycle commands for the local dev stack (mysql + api-server + web-view).
Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile
and do not start with 'up'.
Usage:
scripts/dev.sh up Start the dev stack in the background.
scripts/dev.sh down Stop the stack. Volume kept (DB persists).
scripts/dev.sh reset-db Stop the stack AND drop the mysql volume.
scripts/dev.sh logs Tail logs from the running stack.
scripts/dev.sh status Print 'OK' if mysql container is running, else exit 1.
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
[[ -f .env ]] || { echo "ERROR: .env not found. cp envs/dev/.env.example .env (then edit)." >&2; exit 2; }
case "${1:-}" in
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view
"${COMPOSE[@]}" ps
;;
down)
"${COMPOSE[@]}" down
;;
reset-db)
"${COMPOSE[@]}" down --volumes
"${COMPOSE[@]}" up -d --build mysql api-server web-view
;;
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view
;;
status)
if "${COMPOSE[@]}" ps --status running --services | grep -q '^mysql$'; then
echo OK
else
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 1
fi
;;
-h|--help|help|"")
usage
[[ "${1:-}" == "" ]] && exit 1 || exit 0
;;
*)
echo "unknown command: $1" >&2
usage >&2
exit 1
;;
esac
```
### 5. `scripts/bot_cli.sh`
```bash
#!/usr/bin/env bash
# Run the bot CLI in the local venv. With no args, drops into the TUI menu.
# Requires: dev stack up (run scripts/dev.sh up first), .venv with deps.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
# E2: bail if the dev stack is not running.
if ! NO_SUDO="${NO_SUDO:-0}" bash scripts/dev.sh status >/dev/null 2>&1; then
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 2
fi
[[ -f .env ]] || { echo "ERROR: .env not found. cp envs/dev/.env.example .env (then edit)." >&2; exit 2; }
# Load .env into the environment (export everything between 'set -a' and 'set +a').
set -a
# shellcheck disable=SC1091
source .env
set +a
# Override DB host/port for the local CLI: docker mysql is published on
# 127.0.0.1:3306 even though api-server in-network reaches it as mysql:3306.
export DB_HOST=127.0.0.1
export DB_PORT=3306
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
[[ -x "${PYTHON_BIN}" ]] || { echo "ERROR: ${PYTHON_BIN} not found. Create venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2; exit 2; }
exec "${PYTHON_BIN}" -m app.bot_cli "$@"
```
### 6. `envs/dev/.env.example` (committed)
```
# === Runtime ===
CM_DEBUG=true
# === Deployment Identity ===
CM_DEPLOY_NAME=dev-cm
CM_WEB_HOST_PORT=8000
# === Docker Registry / Build ===
CM_IMAGE_PREFIX=local
DOCKER_IMAGE_TAG=dev
# === Telegram (unused in A2 — telegram-bot is gated by 'bots' profile) ===
TELEGRAM_BOT_TOKEN=fill-only-if-running-bots-profile
TELEGRAM_ALERT_CHAT_ID=
TELEGRAM_ALERT_BOT_TOKEN=
# === Database (dev mysql in docker; bot_cli.sh overrides DB_HOST=127.0.0.1) ===
DB_HOST=mysql
DB_USER=cm
DB_PASSWORD=devpassword
DB_NAME=cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
MYSQL_ROOT_PASSWORD=devroot
# === Bot Config ===
# CM_PREFIX_PATTERN=13c MUST match the seed in docker/mysql/init.d/02-seed.sql.
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=fill-with-real-agent-id-to-test-cm99-calls
CM_AGENT_PASSWORD=fill-with-real-agent-password-to-test-cm99-calls
CM_SECURITY_PIN=000000
CM_BOT_BASE_URL=https://cm99.net
```
Operator workflow: `cp envs/dev/.env.example .env`, fill in real `CM_AGENT_ID` / `CM_AGENT_PASSWORD` only if they want to invoke `register` / `monitor-once` / `credit` / `transfer` against real cm99.net. The `set-pin` and `insert-user` ops also need them.
`envs/dev/.env` (the operator's filled-in copy) is added to `.gitignore`.
### 7. `.gitignore` change
```
__pycache__
.DS_Store
*.html
logs
envs/dev/.env
```
Just `envs/dev/.env` — rex/siong stay tracked (their handling is R2's problem).
### 8. `AGENTS.md` updates
- Replace the section recommending raw schema setup with a pointer to `bash scripts/dev.sh up`.
- Add a new "Dev Tier" subsection: "Local development uses `envs/dev/.env.example``.env``bash scripts/dev.sh up`. The bot CLI: `bash scripts/bot_cli.sh` (TUI) or `bash scripts/bot_cli.sh <subcommand>`."
- Note: the auto-create monitor does NOT run in dev (it lives in `telegram-bot` which is gated by the `bots` profile). Use `bot_cli.sh monitor-once` to exercise that code path manually.
## Files Created / Modified
| File | Operation |
|---|---|
| `docker-compose.override.yml` | Modify — add mysql service, profile-gate bots, depends_on, volume |
| `docker/mysql/init.d/01-schema.sql` | Create |
| `docker/mysql/init.d/02-seed.sql` | Create |
| `app/bot_cli.py` | Create |
| `scripts/dev.sh` | Create |
| `scripts/bot_cli.sh` | Create |
| `envs/dev/.env.example` | Create |
| `.gitignore` | Modify — add `envs/dev/.env` |
| `AGENTS.md` | Modify — dev tier docs |
No new Python or system dependencies. The bot CLI uses argparse + stdlib only; mysql:8.0 is a stable upstream image; everything else is shell + YAML.
## Verification
1. **Cold start.** `cp envs/dev/.env.example .env``bash scripts/dev.sh up`. After the healthcheck settles (~1020s), `docker compose ps` shows mysql, api-server, web-view all `running`. `bash scripts/dev.sh status` prints `OK`.
2. **Schema + seed.** `mysql -h 127.0.0.1 -u cm -pdevpassword cm -e "SELECT username FROM acc"` returns the four seed rows.
3. **API smoke.** `curl http://localhost:3000/acc/` returns the four rows as JSON. `curl http://localhost:8000/api/acc/` proxies the same through the web-view.
4. **CLI no-args = TUI.** `bash scripts/bot_cli.sh` drops into the menu. Pressing `q` exits cleanly.
5. **CLI subcommand parity.** `bash scripts/bot_cli.sh register` (with real agent creds) returns a username/password/link triple matching what Telegram `/1` would return. `bash scripts/bot_cli.sh monitor-once --target 5` reports current pool size and creates accounts up to the target.
6. **Strict mode (E2).** `bash scripts/dev.sh down`, then `bash scripts/bot_cli.sh register` exits 2 with `ERROR: dev stack not running.`
7. **Reset.** `bash scripts/dev.sh reset-db` wipes the volume; on next `up`, the seed is re-applied (verified by step 2).
8. **Prod compose untouched.** `docker compose -f docker-compose.yml config` renders no mysql service, no bots profile gate, no extra ports — identical output to before this change. (rex/siong Portainer stacks unaffected.)
## Risk
Low. Three areas worth naming:
- **Port collision.** `127.0.0.1:3306` collides with a host-side mysql server if one is already running locally. Operators with such a setup would change the host port (`MYSQL_HOST_PORT` env override is *not* added in this design — YAGNI; the .env can map it manually if needed).
- **bot_cli accidentally hitting prod cm99.net.** `register`, `set-pin`, `monitor-once`, `credit`, `transfer` all call cm99.net with real agent credentials. There is no "dry-run" flag in this design. The mitigation is documentation in `envs/dev/.env.example` (placeholder agent creds; operator opts in by replacing them) plus the AGENTS.md note. If a sandbox cm99 environment is ever available, swap `CM_BOT_BASE_URL`.
- **mysql startup race after `reset-db`.** The healthcheck handles this — api-server waits — so it's covered, but worth listing.
- **Latent bug in `set_security_pin_api`'s return contract.** The HAL returns a bool but `cm_telegram.py:87` does `result['f_username']` on it, which would TypeError in the success branch (currently swallowed by the surrounding except). The CLI's `cmd_set_pin` works around it by re-resolving names locally. Fixing the contract belongs in a separate change (HAL refactor), out of scope here so we don't expand A's blast radius.
## Out-of-Scope Follow-Ups
- **Sanitized prod snapshot import**`dev.sh import-snapshot rex` that mysqldumps prod and scrubs sensitive columns before loading. Useful but separate scope.
- **`--dry-run` flag for bot_cli** — record-only mode for `register`/`transfer`. Useful for testing the CLI plumbing without touching cm99.net.
- **Local `web-view` hot-reload** — currently the dev stack rebuilds the web image when files change; a bind mount + flask `--reload` (gated on `CM_DEBUG=true`) would make UI iteration instant. R3-adjacent but mostly an A++ improvement.
- **Migrate rex/siong off committed env files** — the R2 effort. Independent of A.

View File

@ -0,0 +1,312 @@
# Prod Hardening C1+C5+C6 + aaPanel Guide Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Sequel to:** [2026-05-02-debug-mode-hotfix-design.md](2026-05-02-debug-mode-hotfix-design.md), [2026-05-02-local-as-dev-design.md](2026-05-02-local-as-dev-design.md)
**Related sub-projects (not in this scope):** **C3** auth, **C4** rate-limit + scanner deflection, **C7** host firewall — all live in the aaPanel layer; covered in the appendix as a hand-over guide rather than as repo changes. **B** (Next.js webview), **R3** (cm_bot.py scraper resilience) — separate cycles.
## Problem
Three independent issues, all under the "production hardening" bucket, that I'm bundling into one cycle because they all touch the prod Flask container surface and review well together:
1. **Flask dev server in production.** `cm_api` and `cm_web_view` both call `app.run(...)` as their container entrypoint. The Flask docs print a `WARNING: This is a development server.` line into the user's container logs every restart. The dev server is single-threaded, has no graceful reload, no proper signal handling, and is not designed for production load. The earlier debug-mode hotfix (commit `c3f02b3`) closed the RCE risk but left the dev server itself running.
2. **`api-server` host port exposure.** Base `docker-compose.yml` has `ports: - "3000"` for `api-server` (no host binding), which Docker maps to a random host port (e.g. `0.0.0.0:32768->3000`). `api-server` is only ever reached by `web-view` over the compose network — the host port serves no production purpose and broadens the LAN-reachable surface unnecessarily.
3. **Stale documentation + a latent contract bug.** AGENTS.md (line 94) still says `app/cm_bot_hal.py` contains hardcoded agent credentials/PIN, but commit `45303d0` already moved them to env vars (`_get_required_env('CM_AGENT_ID')` etc.). Separately, `cm_bot_hal.set_security_pin_api()` returns a `bool` while `cm_telegram.py:87` does `result['f_username']` — a TypeError currently masked by the surrounding `except Exception` clause. The spec for sub-project A noted this and worked around it in `bot_cli.py`; this is the cycle that fixes it at the source.
The user's reverse proxy (aaPanel) lives on a separate host and reaches the Flask containers over the LAN, so any "edge layer" hardening (TLS, auth, rate limit, scanner deflection) must happen in aaPanel, not in this repo. The appendix below documents what to paste into aaPanel; no repo code implements it.
## Goal
Replace `app.run` with gunicorn in both Flask services for production, hide `api-server`'s host port (only `web-view` stays LAN-reachable for aaPanel), fix the stale doc and the latent HAL contract bug, and write a one-page aaPanel-side hardening guide so the operator can land C3/C4/C7 in their proxy themselves.
## Non-Goals
- Adding Caddy/Traefik/nginx as a docker service. aaPanel already proxies; adding a second proxy in compose would just duplicate concerns. (Was C2; dropped.)
- Implementing auth, rate limit, or scanner deflection in Python middleware. Same reason — wrong layer; aaPanel is where this belongs.
- Writing a host-firewall config script. The aaPanel guide names ufw/iptables rules but doesn't ship a script — host firewall state is too operator-specific.
- Changing `cm_bot.py`'s scraper code. That's R3.
- Migrating to ASGI / uvicorn / async Flask. The app is sync Flask; gunicorn's `sync` worker is the right fit.
## Architecture
### Container entrypoint: gunicorn for prod, `app.run` for dev
The same Docker image runs in both prod (rex/siong via base `docker-compose.yml`) and dev (via `docker-compose.override.yml`). The override pattern is already used to swap registry images for local builds — extending it to swap entrypoints is the natural fit.
| Surface | Today | After |
|---|---|---|
| `docker/api/Dockerfile` `CMD` | `python -m app.cm_api` | `gunicorn --workers 2 --timeout 30 --bind 0.0.0.0:3000 app.cm_api:create_app()` |
| `docker/web/Dockerfile` `CMD` | `python -m app.cm_web_view` | `gunicorn --workers 2 --timeout 30 --bind 0.0.0.0:8000 app.cm_web_view:app` |
| `docker-compose.override.yml` (dev) | (no `command:` overrides) | `command: python -m app.cm_api` for api-server; `command: python -m app.cm_web_view` for web-view |
This keeps Flask's debugger and auto-reloader available in dev (when `CM_DEBUG=true`) without changing any runtime semantics in prod beyond replacing the WSGI server.
#### Why an `app.cm_api:create_app()` factory
`cm_api.py` currently exposes `class CM_API` whose constructor builds a Flask app and registers routes. gunicorn's WSGI loader needs a module-level callable that returns a WSGI app. Smallest viable change: add a `create_app()` module function that does `return CM_API().app`. The class stays — both `python -m app.cm_api` (`__main__` block calls `CM_API().run()`) and `gunicorn 'app.cm_api:create_app()'` work without duplicated bootstrap.
`cm_web_view.py` already has a module-level `app = Flask(__name__)`, so gunicorn binds directly to `app.cm_web_view:app` — no factory needed.
#### Worker count
Two workers per service is the conservative default for a small mostly-DB-bound app. Goes into `gunicorn` flags directly, not into env vars — these aren't operationally tuned right now. Tuning becomes a follow-up if load ever justifies it.
#### Logging
gunicorn writes access + error logs to stdout/stderr by default; `PYTHONUNBUFFERED=1` is already set in compose; aaPanel access logs cover the upstream side. No log routing changes needed.
### `api-server` host port: drop in base, add `127.0.0.1:3000` in dev override
| File | Today | After |
|---|---|---|
| `docker-compose.yml` (api-server) | `ports: - "3000"` | (block removed; api-server reachable only via the compose network) |
| `docker-compose.override.yml` (api-server) | (no ports override) | `ports: - "127.0.0.1:3000:3000"` so dev `curl http://localhost:3000/...` keeps working |
In prod, web-view talks to api-server through the docker bridge network at `http://api-server:3000`. The host port mapping in the base file was incidental, not load-bearing. Removing it makes api-server invisible to the LAN.
`web-view`'s host binding stays as `${CM_WEB_HOST_PORT:-8001}:8000` (no IP prefix → `0.0.0.0`), because aaPanel on a different host needs to reach it over the LAN. That's the intentional public-ish surface; the rest of the docker network goes back to private.
### HAL contract fix: `set_security_pin_api` returns a dict
Today (`app/cm_bot_hal.py:152`), the method ends:
```python
result = self.update_user_status_to_done(f_username)
if result == False:
raise Exception('Failed to update user status to done')
result = self.insert_user_to_table_user(...)
if result == False:
raise Exception('Failed to insert user to table user')
return result # <-- returns bool
```
`cm_telegram.py:87` is what's "right":
```python
result = bot.set_security_pin_api(context.args[0])
del bot
await update.message.reply_text(f"Done setting Security Pin for {result['f_username']} - {result['t_username']} !")
```
Fix the producer, not the consumers. After this change `set_security_pin_api` returns `{"f_username": ..., "t_username": ...}` on success, and the existing `cm_telegram.py` line just works. `app/bot_cli.py` `cmd_set_pin` (added in sub-project A) currently re-extracts names locally as a workaround — that workaround is replaced by reading the dict.
The four `if result == False: raise` lines stay; they're checking the inner step (DB write) returns true, not the outer return shape.
### Cleanups
- `AGENTS.md` line 94 is removed. The "hardcoded credentials" claim is no longer true.
- No other doc edits in this cycle.
## Files Created / Modified
| File | Operation | Purpose |
|---|---|---|
| `requirements.txt` | Modify | Add `gunicorn==23.0.0` (current stable on Python 3.9). |
| `app/cm_api.py` | Modify | Add `def create_app(): return CM_API().app` factory. |
| `app/cm_bot_hal.py` | Modify | `set_security_pin_api` returns `{"f_username", "t_username"}` instead of bool. |
| `app/bot_cli.py` | Modify | `cmd_set_pin` reads `result["f_username"]` / `result["t_username"]` instead of pre-fetching them via `get_whatsapp_link_username`. |
| `tests/test_bot_cli.py` | Modify | Update `CmdSetPinTests` mocks to return the dict. |
| `docker/api/Dockerfile` | Modify | `CMD` swaps to `gunicorn ... app.cm_api:create_app()`. |
| `docker/web/Dockerfile` | Modify | `CMD` swaps to `gunicorn ... app.cm_web_view:app`. |
| `docker-compose.yml` | Modify | Remove `ports: - "3000"` from `api-server`. |
| `docker-compose.override.yml` | Modify | Add `command: python -m app.cm_api` to api-server (preserves Flask dev server in dev); add `command: python -m app.cm_web_view` to web-view; add `ports: - "127.0.0.1:3000:3000"` to api-server. |
| `AGENTS.md` | Modify | Remove the stale "cm_bot_hal.py contains hardcoded credentials" line. |
| `docs/aapanel-hardening.md` | Create | Operator-facing nginx snippets for C3 (auth), C4 (rate-limit + scanner deflection), C7 (host firewall). Pasted into aaPanel; no repo code references it. |
## Verification
1. **Unit tests still green.** `.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli -v``OK`. Updated `CmdSetPinTests` exercises the new dict return shape.
2. **Dev: Flask dev server still runs.** `bash scripts/dev.sh up` → web-view log shows `* Debug mode: on/off` (whichever `CM_DEBUG` is) and `* Running on...`. The `command:` override puts `python -m app.cm_web_view` back in front of gunicorn.
3. **Prod parity check (compose-only).** `docker compose -f docker-compose.yml config | grep -E "Listening|gunicorn" || true; docker compose -f docker-compose.yml config | grep -E "^\s+ports:" -A 1` confirms (a) api-server has no host port, (b) web-view still has `${CM_WEB_HOST_PORT:-8001}:8000`.
4. **Prod cold start (deploy host).** With a published image tag (or a `CM_IMAGE_PREFIX=local DOCKER_IMAGE_TAG=dev` rebuild + `docker compose up -d` from base only), web-view logs show `[INFO] Starting gunicorn` and `[INFO] Listening at: http://0.0.0.0:8000`. No more `WARNING: This is a development server` line.
5. **`/api/acc/` round-trip.** Hit web-view via aaPanel: load the UI, account list renders. The api-server is no longer LAN-reachable on its 3000 port (`nmap -p 3000 <host-ip>` from another machine returns closed). web-view's 8001/8005 still reachable from aaPanel's host.
6. **HAL contract fix.** `python -c "from app.cm_bot_hal import CM_BOT_HAL; print(getattr(CM_BOT_HAL.set_security_pin_api, '__doc__'))"` (or just read the diff) shows the new return shape. `cm_telegram.py:87`'s `result['f_username']` no longer raises in the success path.
7. **Stale doc gone.** `grep -n "hardcoded" AGENTS.md` returns nothing.
## Risk
Medium. Three concerns worth naming:
- **Dropping api-server's host port could surprise an operator** who was relying on `curl http://prod-host:32768/acc/` for ad-hoc debugging in prod. Mitigation: it's mentioned in the AGENTS.md updated section in this cycle, and prod debugging through `docker exec` or via web-view's `/api/acc/` proxy still works.
- **gunicorn worker count and timeout are heuristics, not measurements.** Two workers / 30s timeout is fine for current load (a handful of cm99.net calls in flight); it may need tuning if load grows. Captured as "tune later" out-of-scope item.
- **The HAL return-shape change is a behavior change in the public API of `set_security_pin_api`.** Both call sites are in this repo (`cm_telegram.py`, `app/bot_cli.py`) and both are updated in this cycle. No external consumers exist.
## Out-of-Scope Follow-Ups
- **gunicorn config tuning** (workers, threads, keep-alive) once we have any production traffic data.
- **C3 / C4 / C7** — operator pastes the appendix into aaPanel. If one of them turns out to be repo-relevant after the fact (e.g., we want app-level rate limiting too), it can come back as its own cycle.
- **Authelia (or similar) for passkey-based auth** — the upgrade path from G3 (basic auth + keychain) when biometric UX in basic auth becomes annoying. Self-hosted Authelia container, nginx `auth_request` delegation, WebAuthn enrollment flow. Its own brainstorm cycle when needed.
- **Tailscale-only access** — alternative to public auth: drop the Flask hosts onto a Tailnet, remove the public vhosts. Better phone biometric UX (via Tailscale's app), but loses the "share a public URL" property.
- **Health endpoints** (`/healthz`) for readiness/liveness probes. gunicorn's default 200 on `/` works for now; aaPanel doesn't probe; no orchestrator is doing it. YAGNI.
- **`cm_transfer_credit.py` and `cm_telegram.py`** — neither runs a Flask server, so gunicorn does not apply. Their `restart: unless-stopped` plus the existing crash-resume logic in `cm_telegram.py:run_polling_forever` is the right shape.
---
## Appendix: aaPanel hardening guide (C3 + C4 + C7)
This appendix is the same content that lands at `docs/aapanel-hardening.md`. The repo cycle does not implement these — they are operator actions in aaPanel.
### Threat model recap
aaPanel terminates TLS for `https://<rex-domain>`, `https://<siong-domain>`, and `https://heng.04080616.xyz` (the dev tier — see "Dev vhost" below) and proxies to LAN-reachable web-view ports on the Flask hosts (8001 rex, 8005 siong, 8000 dev). A scanner on the public internet → aaPanel → Flask. Without these mitigations, every `/.env` `/.git/config` `/.aws/config` `/.htpasswd` `/php.php` probe round-trips through the proxy to Flask. With them, aaPanel returns 444 immediately and Flask never sees the request.
### C3 — Basic auth on the rex/siong/dev vhosts
Goal: the web-view UI requires a password. Anyone hitting `https://<domain>/` with no creds gets 401.
Generate an htpasswd file (one per deployment is cleaner):
```bash
# On the aaPanel host, as root:
htpasswd -c /www/server/panel/data/htpasswd-rex rex-operator
htpasswd -c /www/server/panel/data/htpasswd-siong siong-operator
htpasswd -c /www/server/panel/data/htpasswd-dev dev-operator
chmod 640 /www/server/panel/data/htpasswd-*
chown www:www /www/server/panel/data/htpasswd-*
```
Add to the rex vhost's `server { ... }` block (aaPanel: site → settings → "Configuration File"):
```nginx
auth_basic "rex restricted";
auth_basic_user_file /www/server/panel/data/htpasswd-rex;
```
Same shape for siong (`htpasswd-siong`) and dev (`htpasswd-dev`). Use a different password per deployment — reusing the same one means a leaked dev credential exposes prod. Reload nginx (aaPanel does this automatically on save).
**Phone UX note.** Basic auth + iOS/Android keychain + Face ID / Touch ID flow: on first login, save the password into the OS keychain when prompted ("Save password to iCloud Keychain" on iOS, "Save to Google Password Manager" on Android). Subsequent visits trigger Face ID / fingerprint to autofill the basic-auth dialog. Caveats:
- **Safari (iOS):** integration is reliable. Face ID prompts almost every visit unless you tick "Remember me on this device" in Safari's password autofill settings.
- **Chrome (Android):** Google Password Manager autofills basic-auth in newer Chrome versions; biometric prompt appears.
- **In-app browsers (Telegram, WhatsApp link previews):** often *don't* autofill basic-auth and force you to type. If this matters, share `https://...` URLs and ask people to open in their default browser.
If autofill behavior is choppy, the upgrade path is G2 (Authelia + passkeys) — captured as a follow-up below, not in this cycle.
### C4 — Rate limit + scanner deflection
#### Scanner deflection — return 444 on known probe paths
In the same vhost, `server { ... }`:
```nginx
# Deflect generic web vulnerability scanners. Return 444 (no response,
# closes connection) instead of letting them reach Flask.
location ~* "^/(\.env|\.env\..*|\.git/.*|\.aws/.*|\.dockerenv|\.htpasswd|\.npmrc|.+\.php|i\.php|test\.php|php\.php|wp-(login|admin|content)/)" {
access_log off;
return 444;
}
# Robots: tell well-behaved crawlers to leave us alone.
location = /robots.txt {
add_header Content-Type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
```
#### Rate limit — cap requests per source IP
In the `http { ... }` block (one level above `server`; in aaPanel typically lives in the global nginx config or in a snippet):
```nginx
# Define a 10MB shared zone, rate 30 requests/sec per source IP.
limit_req_zone $binary_remote_addr zone=cm_general:10m rate=30r/s;
```
Then inside the rex/siong `server { ... }`:
```nginx
# Allow short bursts (60 reqs above rate) before throttling.
limit_req zone=cm_general burst=60 nodelay;
limit_req_status 429;
```
30 r/s × per-IP is generous for legitimate UI traffic and tight enough to slow a scanner down to nuisance levels.
### Dev vhost — `heng.04080616.xyz` → dev PC
The dev tier (sub-project A) runs locally on a dev PC: `bash scripts/dev.sh up` → web-view on `0.0.0.0:8000`. Routing aaPanel to it adds public reach (with auth) so you can hand someone a URL to test against without giving them VPN.
aaPanel vhost for `heng.04080616.xyz` (in addition to the C3/C4 blocks above):
```nginx
location / {
proxy_pass http://<dev-pc-lan-ip>:8000;
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_read_timeout 60s;
}
```
Replace `<dev-pc-lan-ip>` with the dev PC's address on your LAN.
**⚠️ Important: turn `CM_DEBUG` OFF in `.env` before letting aaPanel proxy to dev.**
The dev tier defaults to `CM_DEBUG=true` (per `envs/dev/.env.example`), which enables Werkzeug's debugger. With aaPanel proxying publicly, basic auth is the only thing standing between the internet and an interactive Python REPL on the dev PC. The right pattern is:
- `CM_DEBUG=true` only when iterating *fully locally* (no aaPanel proxy active, no port forward).
- `CM_DEBUG=false` whenever the dev tier is reachable through `heng.04080616.xyz`.
If you'd rather not flip the flag manually, set `CM_DEBUG=false` permanently in your dev `.env` and run `bash scripts/bot_cli.sh` for the workflows you used to want the debugger for. The Flask in-browser tracebacks aren't worth the RCE surface.
### C7 — Host firewall on the Flask docker host(s)
Restrict the LAN-reachable web-view ports to only aaPanel's IP. Without this, anyone else on the LAN can hit Flask directly and bypass everything in C3 and C4. Apply on each host that runs a Flask stack: rex, siong, *and* the dev PC.
Replace `<aapanel-host-ip>` with the address of your aaPanel box.
On rex/siong hosts (ports 8001 / 8005 respectively):
```bash
sudo ufw allow from <aapanel-host-ip> to any port 8001 proto tcp comment 'rex web-view ← aaPanel only'
sudo ufw allow from <aapanel-host-ip> to any port 8005 proto tcp comment 'siong web-view ← aaPanel only'
sudo ufw deny 8001/tcp
sudo ufw deny 8005/tcp
sudo ufw reload
sudo ufw status numbered
```
On the dev PC (port 8000 — match `CM_WEB_HOST_PORT` from `envs/dev/.env`):
```bash
sudo ufw allow from <aapanel-host-ip> to any port 8000 proto tcp comment 'dev web-view ← aaPanel only'
sudo ufw allow from 127.0.0.1 to any port 8000 proto tcp comment 'dev web-view ← localhost'
sudo ufw deny 8000/tcp
sudo ufw reload
```
The localhost rule on the dev PC is so you can still load `http://localhost:8000` directly while iterating, without going through aaPanel.
Verify from a third machine on the LAN:
```bash
nmap -p 8000,8001,8005 <flask-host-ip>
# All three ports should show 'filtered' from anywhere except the aaPanel host
# (and except localhost on the dev PC).
```
If you don't run ufw and prefer iptables directly, the equivalent rules are:
```bash
iptables -A INPUT -p tcp --dport 8001 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8005 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s <aapanel-host-ip> -j ACCEPT
iptables -A INPUT -p tcp --dport 8000 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 8001 -j DROP
iptables -A INPUT -p tcp --dport 8005 -j DROP
iptables -A INPUT -p tcp --dport 8000 -j DROP
```
(Persist via `iptables-save > /etc/iptables/rules.v4` or your distro's preferred mechanism.)
### Verification after applying C3/C4/C7
1. Curl any UI without creds: `curl -i https://<rex-domain>/``401 Unauthorized`. Same shape for siong and `https://heng.04080616.xyz/`.
2. Curl with creds: `curl -i -u rex-operator:<password> https://<rex-domain>/api/acc/``200 OK` with JSON.
3. Probe a scanner path: `curl -i https://<rex-domain>/.env` → connection closed (444 → curl shows "Empty reply from server"). Flask logs show no entry for this request.
4. Hammer-test rate limit: `for i in $(seq 1 200); do curl -s -o /dev/null -w "%{http_code}\n" https://<rex-domain>/; done | sort | uniq -c` → should see `200`s up to the burst window then `429`s.
5. From a non-aaPanel host on the LAN: `nmap -p 8000,8001,8005 <flask-host-ip>``filtered` (localhost on dev PC still allowed).
6. **Dev-specific check.** On the dev PC, `bash scripts/dev.sh logs | grep "Debugger PIN"` should return nothing once `CM_DEBUG` is off. Then `curl -i -u dev-operator:<password> https://heng.04080616.xyz/api/acc/` returns the seed accounts.

View File

@ -0,0 +1,139 @@
# R3: cm_bot.py Scraper Resilience Design
**Date:** 2026-05-02
**Status:** Approved (design)
**Scope:** Tiny one-shot. Self-contained inside `app/cm_bot.py` plus tests.
**Out of scope:** Caching Struts CSRF tokens — see "Why no token caching" below. Replacing the scraper with an HTTP-API client (cm99.net doesn't expose one — see Playwright probe earlier in this session).
## Problem
`app/cm_bot.py` integrates with cm99.net by parsing HTML responses with BeautifulSoup. When the site re-skins or returns an unexpected page (login expired, session timeout, server error), the existing code fails with cryptic errors like `TypeError: 'NoneType' object is not subscriptable` because lines such as `soup.find('input', {'name': 'token'})['value']` index `None`. The actual response body that caused the failure is lost — there is commented-out HTML-dump code (`with open('credit-{now}.html', 'wb') as f: f.write(response.content)`) hinting that the operator has done this manually before, but it is not active.
The user's broader question earlier in this session was whether to migrate cm99.net integration to a real API; we confirmed it can't because cm99.net is a 2017-era Java/Struts2 monolith that returns server-rendered HTML for everything. So we make the scraper robust instead.
## Goal
Two narrow improvements:
1. **Extract-or-raise helper.** A `_find_input_value(soup, name, context)` instance method that replaces the bare `soup.find('input', {'name': name})['value']` pattern. On miss, it dumps the failing response to `logs/scraper-failures/<context>-<timestamp>.html` and raises a `ScraperError` with the context and what it was looking for. Existing exception handlers in `cm_bot_hal.py` continue to catch and report.
2. **HTML dump on parse failure.** A `_dump_html(context, content)` instance method that writes the raw response bytes to `logs/scraper-failures/`. Called from the helper above, plus from one already-attempted-but-commented site (`get_user_credit`'s text-parsing block) where the failure mode is more involved than a missing input.
## Non-Goals
- Caching the Struts CSRF token across operations. The tokens are anti-CSRF tokens that the Struts2 framework typically regenerates per form-load. Caching one and re-using it for a second POST risks "Invalid token" errors that are silent unless something checks the response body. Without confirmed evidence that this Struts deployment treats tokens as session-scoped (the user has not characterized this), caching is a correctness risk for a perf win we don't measurably need. Skipped intentionally.
- Replacing the bot's `requests`-based transport. Already validated as the right shape (no API exists).
- Touching `cm_telegram.py:87`'s `result['f_username']` line — that was already fixed in commit `231ae69` (the HAL now returns a dict).
- Adding a retry loop on transient HTTP errors. The HAL has its own retry logic (DB) and the bot's exception model is "raise → telegram bot or transfer worker reports". Adding scraper retries would change behavior in ways the operator hasn't asked for.
## Architecture
### `ScraperError` exception class
A small subclass of `Exception` so callers can distinguish scraper-side problems from network errors. Lives at the top of `app/cm_bot.py`.
```python
class ScraperError(Exception):
"""A cm99.net response did not contain the field we expected.
The raw response is saved to logs/scraper-failures/ before this is
raised; the message identifies which method failed and what was
being looked for.
"""
```
### `_dump_html` instance method
Writes the response content to `logs/scraper-failures/<context>-<YYYYMMDD-HHMMSS>.html`. Creates the directory if it doesn't exist. Prints a single line to stdout so the operator notices in the container logs.
```python
def _dump_html(self, context: str, content: bytes) -> str:
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir = os.path.join("logs", "scraper-failures")
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, f"{context}-{ts}.html")
with open(path, "wb") as f:
f.write(content if isinstance(content, (bytes, bytearray)) else content.encode("utf-8", "replace"))
print(f"[scraper-failure] dumped {context} response to {path}")
return path
```
The return value is the path so the helper above can include it in the exception message.
### `_find_input_value` instance method
The dominant pattern in cm_bot.py is `<input name="X" value="...">` extraction. This helper covers it.
```python
def _find_input_value(self, soup, name: str, *, context: str, raw: bytes) -> str:
el = soup.find("input", {"name": name})
if el is None or "value" not in el.attrs:
path = self._dump_html(context, raw)
raise ScraperError(
f"{context}: input[name={name!r}] missing or has no value attribute "
f"(response saved to {path})"
)
return el["value"]
```
The caller passes `raw=response.content` so the dumped file is the actual bytes received, not the BeautifulSoup-stringified version.
### Call sites that adopt the helper
Five existing methods in `app/cm_bot.py` use the bare `soup.find('input', {'name': 'token'})['value']` pattern. Each gets converted in one mechanical edit:
| Method | Before | After |
|---|---|---|
| `get_register_form_token` (line ~344) | `soup.find('input', {'name': 'token'})['value']` | `self._find_input_value(soup, 'token', context='register_form_token', raw=response.content)` |
| `get_security_pin_form_token` (line ~357) | same | `self._find_input_value(soup, 'token', context='security_pin_form_token', raw=response.content)` |
| `get_transfer_token` (line ~463) | same | `self._find_input_value(soup, 'token', context='transfer_token', raw=response.content)` |
| `transfer_credit` (line ~434-436) | three `soup.find('input', ...)` lines for `name`, `token`, `toUserId` | three calls to `_find_input_value` with distinct `context` values |
| `get_register_link` (line ~402-405) | `soup.find('form', {'id': 'qrCodeForm'}).find('a')['href']` | dedicated handling: dump on miss, raise informative ScraperError (this one is not an input lookup) |
The `get_user_credit` method's text-parsing path (`soup.find('table', {...}).find(text=...).parent.parent.find_all('td')[2].text`) is structurally different — it doesn't fit the input-value helper. Its existing `try/except` block already returns 0 on failure. We extend it: in the `except`, dump the response (unconditional, not commented) and `print` the path so the operator sees it. We do not change the return contract (still returns 0 on failure) because callers depend on that.
`get_register_link` (the QR code form) is the other non-input case. We add an explicit `if soup.find(...) is None: self._dump_html(...); raise ScraperError(...)` block — short, no new helper.
### Why no token caching
Struts2 anti-CSRF tokens (the `struts.token.name`/`token` pair) are typically regenerated per form-load: load form → token A → submit → token A is invalidated → load form → token B. Re-using token A for a second submit will fail with "Invalid Token". The current code re-fetches per operation precisely because of this contract. Caching might work if this Struts deployment treats the token as session-scoped, but verifying that requires the operator to test it manually against cm99.net, which:
- Costs real cm99.net traffic
- Is not in this scope
- Saves at most ~100ms per operation, which is not the bot's bottleneck
If a future cycle wants to optimize bot throughput, it can characterize the token contract first. For now: skip.
## Files Created / Modified
| File | Operation |
|---|---|
| `app/cm_bot.py` | Modify — add `ScraperError`, `_dump_html`, `_find_input_value`; convert five call sites + the `get_register_link` and `get_user_credit` failure paths. |
| `tests/test_cm_bot_scraper.py` | Create — unit tests for the three new helpers. |
| `.gitignore` | Already covers `logs/` (existing entry). No change needed. |
No new dependencies. No new tests for existing call sites — those are integration-level and would require live cm99.net or HTML fixtures we don't currently maintain.
## Verification
1. **Unit tests pass.** `.venv/bin/python -m unittest tests.test_cm_bot_scraper -v` exercises:
- `_find_input_value` returns `value` when input exists.
- `_find_input_value` raises `ScraperError` when input is missing, and the error message contains the saved path.
- `_find_input_value` raises `ScraperError` when input has no `value` attribute.
- `_dump_html` creates the directory if missing and writes the bytes verbatim.
- `_dump_html` returns the path it wrote to.
2. **Whole suite still green.** `.venv/bin/python -m unittest tests.test_debug_enabled tests.test_bot_cli tests.test_cm_bot_scraper -v``OK`.
3. **Real-call smoke (deferred to operator).** Trigger a bot operation against cm99.net. If anything fails, an HTML file appears under `logs/scraper-failures/<context>-<timestamp>.html` and the container logs print the path. Existing happy paths are unchanged.
## Risk
Low.
- **The new helpers' behavior is byte-equivalent to the existing code on the happy path** (input exists with `value`). The `value` is returned exactly the same way.
- **Failure mode changes** from `TypeError: 'NoneType' object is not subscriptable` to `ScraperError: <context>: input[name='token'] missing or has no value (response saved to logs/...)`. Existing exception handlers in `cm_bot_hal.py` (`except Exception as e: ...`, `except: ...`) catch both equally.
- **Disk usage:** failing HTML responses go into `logs/scraper-failures/`. cm99.net pages are ~1030KB each. At a few failures per day this is negligible. If cm99.net misbehaves badly (every request fails) the operator has bigger problems anyway and will see the spam in container logs.
## Out-of-Scope Follow-Ups
- **Struts token caching after characterizing the contract.** Not now.
- **Periodic cleanup of `logs/scraper-failures/`** (e.g., a `find logs/ -name '*.html' -mtime +14 -delete` cron). YAGNI; revisit if disk usage becomes a concern.
- **HTML fixture-based regression tests** for the bot operations themselves. Useful long-term; large enough scope to be its own cycle.

40
envs/dev/.env.example Normal file
View File

@ -0,0 +1,40 @@
# Local development environment variables.
# Copy this file to repo-root .env and fill in real cm99.net agent credentials
# only if you want CLI ops (register/set-pin/credit/transfer/monitor-once)
# to actually call cm99.net. The DB is local-only via docker-compose.override.yml.
# === Runtime ===
CM_DEBUG=true
# === Deployment Identity ===
CM_DEPLOY_NAME=dev-cm
CM_WEB_HOST_PORT=8000
CM_WEB_NEXT_HOST_PORT=8010
# === Docker Registry / Build ===
CM_IMAGE_PREFIX=local
DOCKER_IMAGE_TAG=dev
# === Telegram (unused in A2 — telegram-bot is gated by 'bots' profile) ===
TELEGRAM_BOT_TOKEN=fill-only-if-running-bots-profile
TELEGRAM_ALERT_CHAT_ID=
TELEGRAM_ALERT_BOT_TOKEN=
# === Database (dev mysql in docker; bot_cli.sh overrides DB_HOST=127.0.0.1) ===
DB_HOST=mysql
DB_USER=cm
DB_PASSWORD=devpassword
DB_NAME=cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
MYSQL_ROOT_PASSWORD=devroot
# === Bot Config ===
# CM_PREFIX_PATTERN=13c MUST match the seed in docker/mysql/init.d/02-seed.sql.
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=fill-with-real-agent-id-to-test-cm99-calls
CM_AGENT_PASSWORD=fill-with-real-agent-password-to-test-cm99-calls
CM_SECURITY_PIN=000000
CM_BOT_BASE_URL=https://cm99.net

View File

@ -1,28 +0,0 @@
# === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=5315819168:AAH31xwNgPdnk123x97XalmTW6fQV5EUCFU
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=rex_cm
DB_PASSWORD=hengserver
DB_NAME=rex_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=cm13a3
CM_AGENT_PASSWORD=Sky533535
CM_SECURITY_PIN=Sky533535
CM_BOT_BASE_URL=https://cm99.net

39
envs/rex/.env.example Normal file
View File

@ -0,0 +1,39 @@
# rex deployment template. Copy to envs/rex/.env (which is gitignored) and
# fill in the real secrets for the rex environment, OR paste the variables
# directly into the Portainer stack environment.
# === Runtime ===
# Leave unset (or 'false') in production. Setting CM_DEBUG=true exposes the
# Werkzeug debugger and is RCE if the port is reachable.
CM_DEBUG=false
# === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001
CM_WEB_NEXT_HOST_PORT=8011
# === Docker Registry / Build ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALERT_CHAT_ID=
TELEGRAM_ALERT_BOT_TOKEN=
# === Database ===
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=
CM_AGENT_ID=
CM_AGENT_PASSWORD=
CM_SECURITY_PIN=
CM_BOT_BASE_URL=

View File

@ -1,28 +0,0 @@
# === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=7028479329:AAH_UTPoYcaB0iZMXJjO7pKYxyub8ZSXn2E
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=siong_cm
DB_PASSWORD=hengserver
DB_NAME=siong_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13sa
CM_AGENT_ID=cm13a39
CM_AGENT_PASSWORD=Wenwen12345
CM_SECURITY_PIN=Wenwen12345
CM_BOT_BASE_URL=https://cm99.net

39
envs/siong/.env.example Normal file
View File

@ -0,0 +1,39 @@
# siong deployment template. Copy to envs/siong/.env (which is gitignored) and
# fill in the real secrets for the siong environment, OR paste the variables
# directly into the Portainer stack environment.
# === Runtime ===
# Leave unset (or 'false') in production. Setting CM_DEBUG=true exposes the
# Werkzeug debugger and is RCE if the port is reachable.
CM_DEBUG=false
# === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005
CM_WEB_NEXT_HOST_PORT=8012
# === Docker Registry / Build ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALERT_CHAT_ID=
TELEGRAM_ALERT_BOT_TOKEN=
# === Database ===
DB_HOST=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=
CM_AGENT_ID=
CM_AGENT_PASSWORD=
CM_SECURITY_PIN=
CM_BOT_BASE_URL=

View File

@ -4,4 +4,5 @@ flask-cors==4.0.0
python-telegram-bot==22.4 python-telegram-bot==22.4
requests==2.32.5 requests==2.32.5
beautifulsoup4==4.13.5 beautifulsoup4==4.13.5
tqdm==4.67.1 tqdm==4.67.1
gunicorn==23.0.0

62
scripts/bot_cli.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Run the bot CLI in the local venv. With no args, drops into the TUI menu.
# Requires: dev stack up (run scripts/dev.sh up first), .venv with deps.
set -euo pipefail
usage() {
cat <<'EOF'
Run the bot CLI (app.bot_cli) in the local venv.
Usage:
scripts/bot_cli.sh Drop into the TUI menu.
scripts/bot_cli.sh <subcommand> [args] One-shot subcommand. Try --help.
Examples:
scripts/bot_cli.sh register
scripts/bot_cli.sh credit 13c1234 abc12345
scripts/bot_cli.sh monitor-once --target 5
Environment:
NO_SUDO=1 Skip 'sudo' when checking 'dev.sh status'.
PYTHON_BIN Override the python interpreter (default: .venv/bin/python).
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
# E2: bail if the dev stack is not running.
if ! NO_SUDO="${NO_SUDO:-0}" bash scripts/dev.sh status >/dev/null 2>&1; then
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 2
fi
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. cp envs/dev/.env.example .env (then edit)." >&2
exit 2
fi
# Load .env into the environment (export everything between 'set -a' and 'set +a').
set -a
# shellcheck disable=SC1091
source .env
set +a
# Override DB host/port for the local CLI: docker mysql is published on
# 127.0.0.1:3306, even though api-server in-network reaches it as mysql:3306.
export DB_HOST=127.0.0.1
export DB_PORT=3306
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
echo "ERROR: ${PYTHON_BIN} not found." >&2
echo "Create the venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
exit 2
fi
exec "${PYTHON_BIN}" -m app.bot_cli "$@"

80
scripts/dev.sh Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Lifecycle commands for the local dev stack (mysql + api-server + web-view).
# Bots (telegram-bot, transfer-bot) are gated behind a compose 'bots' profile
# and do not start with 'up'. Status is used by scripts/bot_cli.sh.
set -euo pipefail
usage() {
cat <<'EOF'
Lifecycle for the dev stack (mysql + api-server + web-view + web-next).
Usage:
scripts/dev.sh up Start all dev services in the background.
scripts/dev.sh down Stop the stack. mysql volume kept (DB persists).
scripts/dev.sh reset-db Stop the stack AND drop the mysql volume; then start.
scripts/dev.sh logs Tail logs from the running stack.
scripts/dev.sh status Print 'OK' if mysql is running, else exit 1.
Environment:
NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group).
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
# shellcheck disable=SC2206
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
# Help and empty-arg cases don't need .env. Handle them first.
case "${1:-}" in
-h|--help|help)
usage
exit 0
;;
"")
usage >&2
exit 1
;;
esac
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at repo root. Run: cp envs/dev/.env.example .env (then edit)." >&2
exit 2
fi
case "${1:-}" in
up)
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
"${COMPOSE[@]}" ps
;;
down)
# --remove-orphans cleans up containers from manual `docker compose
# -f docker-compose.yml ...` invocations (e.g., the prod-mode gunicorn
# smoke test) that landed in the same compose project but aren't
# services in the override.
"${COMPOSE[@]}" down --remove-orphans
;;
reset-db)
"${COMPOSE[@]}" down --volumes --remove-orphans
"${COMPOSE[@]}" up -d --build mysql api-server web-view web-next
;;
logs)
"${COMPOSE[@]}" logs -f mysql api-server web-view web-next
;;
status)
if "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -q '^mysql$'; then
echo OK
else
echo "ERROR: dev stack not running. Run 'scripts/dev.sh up' first." >&2
exit 1
fi
;;
*)
echo "unknown command: $1" >&2
usage >&2
exit 1
;;
esac

View File

@ -60,6 +60,7 @@ SERVICES=(
"telegram docker/telegram/Dockerfile" "telegram docker/telegram/Dockerfile"
"web docker/web/Dockerfile" "web docker/web/Dockerfile"
"transfer docker/transfer/Dockerfile" "transfer docker/transfer/Dockerfile"
"web-next docker/web-next/Dockerfile"
) )
echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-<service>:${IMAGE_TAG}" echo "Publishing CM Bot images to ${REGISTRY_PREFIX}/cm-<service>:${IMAGE_TAG}"

120
scripts/verify_debug.sh Executable file
View File

@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Verify the CM_DEBUG env toggle on the cm-web container.
# No DB required — web-view has no DB dependency, and we use --no-deps
# to skip api-server (which needs MySQL).
set -euo pipefail
usage() {
cat <<'EOF'
Verify the CM_DEBUG env toggle on the cm-web container.
Usage:
scripts/verify_debug.sh
What it does:
Brings up web-view twice — once with CM_DEBUG=true, once unset (default
false) — greps the container logs for the Werkzeug "Debug mode" banner
and the "Debugger PIN" line, and reports pass/fail. Tears down on exit.
Requirements:
- docker compose (v2 plugin)
- sudo (matches scripts/local_build.sh; set NO_SUDO=1 to skip sudo)
- .env at the repo root (copy envs/rex/.env or envs/siong/.env first)
Exit code:
0 if both modes behave correctly, non-zero otherwise.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
# shellcheck disable=SC2206
COMPOSE=(${SUDO} docker compose -f docker-compose.yml -f docker-compose.override.yml)
SERVICE=web-view
WAIT_SECS=20
if [[ ! -f .env ]]; then
echo "ERROR: .env not found at repo root. Copy envs/rex/.env (or envs/siong/.env) first." >&2
exit 2
fi
cleanup() {
echo
echo "==> Cleaning up..."
"${COMPOSE[@]}" down --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_for_banner() {
local left=$WAIT_SECS
while (( left > 0 )); do
if "${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1 | grep -q "Debug mode:"; then
return 0
fi
sleep 1
((left--))
done
return 1
}
check_logs() {
local expected_debug="$1" expect_pin="$2"
local logs
logs="$("${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}" 2>&1)"
if ! echo "${logs}" | grep -q "Debug mode: ${expected_debug}"; then
echo "FAIL: expected 'Debug mode: ${expected_debug}' in ${SERVICE} logs"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
if [[ "${expect_pin}" == "yes" ]]; then
if ! echo "${logs}" | grep -q "Debugger PIN:"; then
echo "FAIL: expected 'Debugger PIN:' line, none found"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
else
if echo "${logs}" | grep -q "Debugger PIN:"; then
echo "FAIL: 'Debugger PIN:' line present when CM_DEBUG should be off"
echo "--- captured logs ---"
echo "${logs}"
return 1
fi
fi
}
run_mode() {
local mode="$1" expected_debug="$2" expect_pin="$3"
echo "==> CM_DEBUG=${mode} — expecting 'Debug mode: ${expected_debug}', PIN ${expect_pin}"
CM_DEBUG="${mode}" "${COMPOSE[@]}" up -d --build --no-deps "${SERVICE}" >/dev/null
if ! wait_for_banner; then
echo "FAIL: ${SERVICE} did not print 'Debug mode:' banner within ${WAIT_SECS}s"
"${COMPOSE[@]}" logs --no-color --tail=80 "${SERVICE}"
return 1
fi
check_logs "${expected_debug}" "${expect_pin}" || return 1
echo "PASS"
echo
"${COMPOSE[@]}" stop "${SERVICE}" >/dev/null
}
run_mode "true" "on" "yes" || exit 1
run_mode "false" "off" "no" || exit 1
echo "All CM_DEBUG verifications passed."

0
tests/__init__.py Normal file
View File

310
tests/test_bot_cli.py Normal file
View File

@ -0,0 +1,310 @@
"""Tests for the bot CLI (app.bot_cli).
The CLI mirrors the Telegram bot's manual-trigger surface (Telegram
handlers /1, /2, /3) plus the operational ops (credit, transfer,
monitor-once). With no args, it drops into a stdlib TUI menu.
These tests mock app.bot_cli.CM_BOT_HAL so they never touch the database
or cm99.net. The HAL class is imported at module load (which is a pure
import no env reads), so we can patch the symbol bound on app.bot_cli
without affecting other tests.
"""
import argparse
import contextlib
import io
import os
import sys
import unittest
from unittest import mock
import app.bot_cli as bot_cli
class ParserSanityTests(unittest.TestCase):
def test_build_parser_returns_argument_parser(self):
parser = bot_cli.build_parser()
self.assertIsInstance(parser, argparse.ArgumentParser)
def test_main_with_no_args_dispatches_to_interactive(self):
# When invoked with no subcommand, main() should drop into the
# TUI loop. We verify the dispatch by patching cmd_interactive to
# a no-op recorder.
with mock.patch.object(bot_cli, "cmd_interactive", return_value=0) as mocked:
rc = bot_cli.main([])
mocked.assert_called_once()
self.assertEqual(rc, 0)
class CmdRegisterTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_username_password_link(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_api.return_value = {
"username": "13c1234",
"password": "abc12345",
"link": "https://example.com/r/foo",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_register(argparse.Namespace())
text = out.getvalue()
self.assertIn("Username: 13c1234", text)
self.assertIn("Password: abc12345", text)
self.assertIn("Link: https://example.com/r/foo", text)
mock_hal.get_user_api.assert_called_once_with()
def test_register_subparser_dispatches_to_cmd_register(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["register"])
self.assertIs(args.func, bot_cli.cmd_register)
def test_get_acc_alias_dispatches_to_cmd_register(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["get-acc"])
self.assertIs(args.func, bot_cli.cmd_register)
class CmdSetPinTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_rejects_invalid_whatsapp_url(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_set_pin(argparse.Namespace(link="https://not-whatsapp.example/x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.set_security_pin_api.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_names_from_hal_return_dict(self, mock_hal_class):
# set_security_pin_api now returns a dict on success and raises
# on any failure path. cmd_set_pin reads names directly from the
# dict instead of pre-fetching them via get_whatsapp_link_username.
mock_hal = mock_hal_class.return_value
mock_hal.is_whatsapp_url.return_value = True
mock_hal.set_security_pin_api.return_value = {
"f_username": "f_user_42",
"t_username": "t_user_42",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_set_pin(argparse.Namespace(link="https://chat.whatsapp.com/abc"))
text = out.getvalue()
self.assertIn("f_username=f_user_42", text)
self.assertIn("t_username=t_user_42", text)
# The local get_whatsapp_link_username call from the old workaround
# is gone — the HAL resolves names internally.
mock_hal.get_whatsapp_link_username.assert_not_called()
mock_hal.set_security_pin_api.assert_called_once_with("https://chat.whatsapp.com/abc")
def test_set_pin_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["set-pin", "https://chat.whatsapp.com/abc"])
self.assertIs(args.func, bot_cli.cmd_set_pin)
self.assertEqual(args.link, "https://chat.whatsapp.com/abc")
class CmdInsertUserTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_inserts_using_password_lookup_and_security_pin(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = "abc12345"
mock_hal.security_pin = "999111"
mock_hal.insert_user_to_table_user.return_value = True
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x"))
self.assertIn("OK: inserted 13c1234 → player_x", out.getvalue())
mock_hal.insert_user_to_table_user.assert_called_once_with({
"f_username": "13c1234",
"f_password": "abc12345",
"t_username": "player_x",
"t_password": "999111",
})
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_no_password_for_f_user_exits_2(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = None
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_insert_user(argparse.Namespace(f_username="missing", t_username="player_x"))
self.assertEqual(cm.exception.code, 2)
mock_hal.insert_user_to_table_user.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_insert_failure_exits_1(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_pass_from_acc.return_value = "abc"
mock_hal.security_pin = "000"
mock_hal.insert_user_to_table_user.return_value = False
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_insert_user(argparse.Namespace(f_username="13c1234", t_username="player_x"))
self.assertEqual(cm.exception.code, 1)
def test_insert_user_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["insert-user", "13c1234", "player_x"])
self.assertIs(args.func, bot_cli.cmd_insert_user)
self.assertEqual(args.f_username, "13c1234")
self.assertEqual(args.t_username, "player_x")
class CmdCreditTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_credit(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_credit.return_value = 42.5
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_credit(argparse.Namespace(username="13c1234", password="abc"))
self.assertIn("Credit: 42.5", out.getvalue())
mock_hal.get_user_credit.assert_called_once_with("13c1234", "abc")
def test_credit_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["credit", "13c1234", "abc"])
self.assertIs(args.func, bot_cli.cmd_credit)
class CmdTransferTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_prints_transfer_result(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.transfer_credit_api.return_value = "Successfully transfer amount: 10.0 from 13c1234 to player_x"
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_transfer(argparse.Namespace(
f_username="13c1234", f_password="abc",
t_username="player_x", t_password="0000",
))
self.assertIn("Successfully transfer", out.getvalue())
mock_hal.transfer_credit_api.assert_called_once_with("13c1234", "abc", "player_x", "0000")
def test_transfer_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["transfer", "13c1234", "abc", "player_x", "0000"])
self.assertIs(args.func, bot_cli.cmd_transfer)
class CmdMonitorOnceTests(unittest.TestCase):
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_does_nothing_when_already_at_target(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = [{"username": f"u{i}"} for i in range(20)]
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_monitor_once(argparse.Namespace(target=20))
text = out.getvalue()
self.assertIn("Available accounts: 20", text)
self.assertIn("Already at target", text)
mock_hal.create_new_acc.assert_not_called()
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_creates_accounts_until_target(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = [{"username": "u1"}, {"username": "u2"}]
mock_hal.create_new_acc.side_effect = [
{"username": "u3", "password": "p3", "link": "l3"},
{"username": "u4", "password": "p4", "link": "l4"},
{"username": "u5", "password": "p5", "link": "l5"},
]
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_monitor_once(argparse.Namespace(target=5))
text = out.getvalue()
self.assertEqual(mock_hal.create_new_acc.call_count, 3)
self.assertIn("Created: u3", text)
self.assertIn("Created: u4", text)
self.assertIn("Created: u5", text)
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_create_failure_exits_1(self, mock_hal_class):
mock_hal = mock_hal_class.return_value
mock_hal.get_all_available_acc.return_value = []
mock_hal.create_new_acc.side_effect = RuntimeError("fail login")
with self.assertRaises(SystemExit) as cm:
bot_cli.cmd_monitor_once(argparse.Namespace(target=1))
self.assertEqual(cm.exception.code, 1)
def test_monitor_once_subparser_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["monitor-once", "--target", "7"])
self.assertIs(args.func, bot_cli.cmd_monitor_once)
self.assertEqual(args.target, 7)
def test_monitor_alias_dispatches(self):
parser = bot_cli.build_parser()
args = parser.parse_args(["monitor"])
self.assertIs(args.func, bot_cli.cmd_monitor_once)
self.assertEqual(args.target, 20)
class CmdInteractiveTests(unittest.TestCase):
@mock.patch("builtins.input", side_effect=["q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_q_exits_cleanly(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("CM Bot CLI", out.getvalue())
@mock.patch("builtins.input", side_effect=["", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_blank_line_continues_loop(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertGreaterEqual(out.getvalue().count("Register / get next account"), 2)
@mock.patch("builtins.input", side_effect=EOFError)
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_eof_exits_cleanly(self, mock_hal_class, mock_input):
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("CM Bot CLI", out.getvalue())
@mock.patch("builtins.input", side_effect=["1", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_alias_1_dispatches_to_register(self, mock_hal_class, mock_input):
mock_hal = mock_hal_class.return_value
mock_hal.get_user_api.return_value = {
"username": "u", "password": "p", "link": "l",
}
out = io.StringIO()
with contextlib.redirect_stdout(out):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("Username: u", out.getvalue())
mock_hal.get_user_api.assert_called_once_with()
@mock.patch("builtins.input", side_effect=["nonsense", "q"])
@mock.patch.object(bot_cli, "CM_BOT_HAL")
def test_unknown_subcommand_keeps_loop_alive(self, mock_hal_class, mock_input):
out = io.StringIO()
err = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
bot_cli.cmd_interactive(argparse.Namespace())
self.assertIn("CM Bot CLI", out.getvalue())
class CreateAppFactoryTests(unittest.TestCase):
"""The gunicorn entrypoint loads `app.cm_api:create_app()`. The factory
must exist as a module-level callable that returns the Flask app
object not the CM_API wrapper class."""
def test_create_app_returns_flask_instance(self):
from flask import Flask
from app.cm_api import create_app
wsgi = create_app()
self.assertIsInstance(wsgi, Flask)
def test_create_app_registers_acc_route(self):
from app.cm_api import create_app
wsgi = create_app()
rules = {r.rule for r in wsgi.url_map.iter_rules()}
self.assertIn("/acc/", rules)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,127 @@
"""Tests for the cm_bot scraper resilience helpers.
The CM_BOT class currently uses bare `soup.find(...)['value']` calls
that throw cryptic TypeErrors when cm99.net returns an unexpected
response. R3 introduces three pieces:
- ScraperError: typed exception so callers can distinguish scraper
failures from network errors.
- _dump_html(context, content): writes the failing response to
logs/scraper-failures/<context>-<ts>.html and returns the path.
- _find_input_value(soup, ident, *, context, raw, by='name'):
the dominant extraction pattern. Returns the value on success,
dumps + raises ScraperError on miss.
These tests do NOT exercise the live cm99.net integration. They use
small inline HTML fixtures and patch filesystem side effects so the
tests stay hermetic.
"""
import os
import shutil
import tempfile
import unittest
from unittest import mock
from bs4 import BeautifulSoup
from app.cm_bot import CM_BOT, ScraperError
class ScraperHelpersTests(unittest.TestCase):
def setUp(self):
# CM_BOT.__init__ reads CM_BOT_BASE_URL from the env. Patch it
# in setUp (mock.patch.dict as a class decorator only wraps
# test_* methods, so setUp would see an unpatched env).
self._env_patcher = mock.patch.dict(
os.environ, {"CM_BOT_BASE_URL": "https://example.invalid"}
)
self._env_patcher.start()
self.addCleanup(self._env_patcher.stop)
# Each test gets a fresh tmpdir so the dump helper writes
# somewhere predictable. We chdir into it for the duration of
# the test because _dump_html writes to a relative
# logs/scraper-failures path.
self._old_cwd = os.getcwd()
self._tmp = tempfile.mkdtemp(prefix="r3-test-")
os.chdir(self._tmp)
self.bot = CM_BOT()
def tearDown(self):
os.chdir(self._old_cwd)
shutil.rmtree(self._tmp, ignore_errors=True)
# ---- _dump_html ----
def test_dump_html_creates_dir_and_writes_bytes(self):
path = self.bot._dump_html("ctx-test", b"<html>hi</html>")
self.assertTrue(os.path.isfile(path), f"file should exist: {path}")
with open(path, "rb") as f:
self.assertEqual(f.read(), b"<html>hi</html>")
self.assertTrue(path.startswith(os.path.join("logs", "scraper-failures")))
def test_dump_html_accepts_str_content(self):
path = self.bot._dump_html("ctx-test", "<html>hi</html>")
with open(path, "rb") as f:
self.assertEqual(f.read(), b"<html>hi</html>")
def test_dump_html_includes_context_and_timestamp_in_filename(self):
path = self.bot._dump_html("register_form_token", b"x")
basename = os.path.basename(path)
self.assertTrue(basename.startswith("register_form_token-"), basename)
self.assertTrue(basename.endswith(".html"), basename)
# ---- _find_input_value ----
def test_find_input_value_returns_value_when_present(self):
html = '<form><input name="token" value="abc123"></form>'
soup = BeautifulSoup(html, "html.parser")
result = self.bot._find_input_value(
soup, "token", context="happy_path", raw=html.encode()
)
self.assertEqual(result, "abc123")
def test_find_input_value_raises_and_dumps_when_missing(self):
html = '<form><input name="other" value="x"></form>'
soup = BeautifulSoup(html, "html.parser")
with self.assertRaises(ScraperError) as cm:
self.bot._find_input_value(
soup, "token", context="missing_input", raw=html.encode()
)
msg = str(cm.exception)
self.assertIn("missing_input", msg)
self.assertIn("token", msg)
dumped = os.listdir(os.path.join("logs", "scraper-failures"))
self.assertEqual(len(dumped), 1, f"expected one dump, got {dumped}")
self.assertTrue(dumped[0].startswith("missing_input-"))
def test_find_input_value_raises_when_input_has_no_value_attr(self):
html = '<form><input name="token"></form>'
soup = BeautifulSoup(html, "html.parser")
with self.assertRaises(ScraperError):
self.bot._find_input_value(
soup, "token", context="no_value_attr", raw=html.encode()
)
def test_find_input_value_does_not_dump_on_success(self):
html = '<form><input name="token" value="abc"></form>'
soup = BeautifulSoup(html, "html.parser")
self.bot._find_input_value(
soup, "token", context="should_not_dump", raw=html.encode()
)
self.assertFalse(
os.path.isdir(os.path.join("logs", "scraper-failures")),
"happy path should not create the failure dir",
)
def test_find_input_value_supports_by_id(self):
html = '<form><input id="toUserId" value="42"></form>'
soup = BeautifulSoup(html, "html.parser")
result = self.bot._find_input_value(
soup, "toUserId", context="by_id", raw=html.encode(), by="id",
)
self.assertEqual(result, "42")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,84 @@
"""Regression tests for the _debug_enabled helper.
Both app.cm_api and app.cm_web_view define a private _debug_enabled()
function that parses the CM_DEBUG environment variable. They are
intentionally duplicated (only two call sites; no shared utility module
exists). This test runs the same parametrized cases against every copy
to catch drift if one is updated without the other.
"""
import os
import unittest
from unittest import mock
# Import the modules at top-level (before any mock.patch.dict with
# clear=True), so module-load-time os.getenv() reads see the real
# environment. The patches inside individual tests then only affect the
# helper's runtime read of CM_DEBUG.
import app.cm_api
import app.cm_web_view
# Modules expected to expose a private _debug_enabled() helper.
# Add new entries here if more Flask entrypoints adopt the same toggle.
HELPER_MODULES = (
app.cm_web_view,
app.cm_api,
)
# (env_value, expected_result). env_value=None means CM_DEBUG is unset.
CASES = (
(None, False),
("", False),
("false", False),
("False", False),
("FALSE", False),
("0", False),
("no", False),
("anything-else", False),
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("YES", True),
(" true ", True),
)
class DebugEnabledTests(unittest.TestCase):
def _resolve(self, module):
return getattr(module, "_debug_enabled", None)
def test_helper_exists_on_every_module(self):
for module in HELPER_MODULES:
with self.subTest(module=module.__name__):
helper = self._resolve(module)
self.assertTrue(
callable(helper),
f"{module.__name__}._debug_enabled must be callable",
)
def test_parses_cm_debug_consistently(self):
for module in HELPER_MODULES:
helper = self._resolve(module)
if helper is None:
self.fail(
f"{module.__name__}._debug_enabled is missing — "
"make test_helper_exists_on_every_module pass first"
)
for env_value, expected in CASES:
with self.subTest(module=module.__name__, env=env_value):
env = {} if env_value is None else {"CM_DEBUG": env_value}
with mock.patch.dict(os.environ, env, clear=True):
self.assertEqual(
helper(),
expected,
f"{module.__name__}._debug_enabled() should be "
f"{expected!r} for CM_DEBUG={env_value!r}",
)
if __name__ == "__main__":
unittest.main()

5
web/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.next
.git
.gitignore
README.md

8
web/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
/.next/
/out/
/build/
.DS_Store
*.tsbuildinfo
next-env.d.ts.bak
.env*.local

67
web/app/actions.ts Normal file
View File

@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";
import type { AccUpdate, UserUpdate } from "@/lib/types";
export type ActionResult = { ok: true } | { ok: false; error: string };
export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-acc-data", { method: "POST", body: data });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function updateUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-user-data", { method: "POST", body: data });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-acc-data", { method: "POST", body: data });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function createUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-user-data", { method: "POST", body: data });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function deleteAccount(username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function deleteUser(f_username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-user-data", { method: "POST", body: { f_username } });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

47
web/app/apple-icon.tsx Normal file
View File

@ -0,0 +1,47 @@
import { ImageResponse } from "next/og";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#18181b",
color: "white",
fontFamily: '"Helvetica", "Arial", sans-serif',
position: "relative",
}}
>
<span
style={{
fontSize: 96,
fontWeight: 600,
letterSpacing: "-4px",
lineHeight: 1,
}}
>
CM
</span>
<div
style={{
position: "absolute",
top: 22,
right: 22,
width: 12,
height: 12,
borderRadius: 999,
background: "#10b981",
}}
/>
</div>
),
size,
);
}

66
web/app/error.tsx Normal file
View File

@ -0,0 +1,66 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Top-level error boundary caught:", error);
}, [error]);
return (
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
<div className="w-full max-w-lg overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60">
<div className="flex items-center gap-3 border-b border-zinc-100 px-6 py-4">
<span
aria-hidden="true"
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-red-100 text-xs font-semibold text-red-700"
>
!
</span>
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
API unreachable
</span>
</div>
<div className="px-6 py-6 sm:px-8">
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900">
Couldn&apos;t reach the API
</h1>
<p className="mt-2 text-sm leading-relaxed text-zinc-600">
The dashboard fetches data from{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
api-server:3000
</code>{" "}
on the internal docker network. The container may be down or
still starting. Wait a few seconds and retry.
</p>
<button
type="button"
onClick={reset}
className="mt-6 inline-flex items-center gap-2 rounded-full bg-zinc-900 px-5 py-2 text-xs font-medium text-white transition-colors hover:bg-zinc-700"
>
Retry
<span aria-hidden="true"></span>
</button>
<div className="mt-8 border-t border-zinc-100 pt-4">
<div className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Error
</div>
<pre className="mt-2 overflow-x-auto rounded-md bg-zinc-50 p-3 font-mono text-xs text-zinc-700 ring-1 ring-zinc-200/60">
{error.message}
{error.digest ? `\n\ndigest: ${error.digest}` : ""}
</pre>
</div>
</div>
</div>
</div>
);
}

1
web/app/globals.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

48
web/app/icon.tsx Normal file
View File

@ -0,0 +1,48 @@
import { ImageResponse } from "next/og";
export const size = { width: 512, height: 512 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#18181b",
borderRadius: 96,
color: "white",
fontFamily: '"Helvetica", "Arial", sans-serif',
position: "relative",
}}
>
<span
style={{
fontSize: 280,
fontWeight: 600,
letterSpacing: "-12px",
lineHeight: 1,
}}
>
CM
</span>
<div
style={{
position: "absolute",
top: 64,
right: 64,
width: 32,
height: 32,
borderRadius: 999,
background: "#10b981",
}}
/>
</div>
),
size,
);
}

36
web/app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import Nav from "@/components/nav";
import AutoRefresh from "@/components/auto-refresh";
export const metadata: Metadata = {
title: "CM Bot V2",
description: "CM Bot account and user dashboard",
};
export const viewport: Viewport = {
themeColor: "#18181b",
// Lets the page draw under the iPhone notch / Dynamic Island when the
// PWA runs in standalone mode. Components that pin to the edges (Nav,
// Toast) read env(safe-area-inset-*) to keep their content out of the
// hardware cutouts.
viewportFit: "cover",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen bg-zinc-50 text-zinc-900 antialiased">
<Nav />
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
{children}
</main>
<AutoRefresh />
</body>
</html>
);
}

18
web/app/manifest.ts Normal file
View File

@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "CM Bot V2",
short_name: "CM Bot",
description: "CM Bot account and user dashboard",
start_url: "/",
display: "standalone",
orientation: "portrait",
background_color: "#fafafa",
theme_color: "#18181b",
icons: [
{ src: "/icon", sizes: "any", type: "image/png" },
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
],
};
}

9
web/app/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { getAccounts } from "@/lib/api";
import AccountsTable from "@/components/accounts-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function AccountsPage() {
const accounts = await getAccounts();
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
}

9
web/app/users/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { getUsers } from "@/lib/api";
import UsersTable from "@/components/users-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function UsersPage() {
const users = await getUsers();
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
}

View File

@ -0,0 +1,404 @@
"use client";
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { Acc } from "@/lib/types";
import { deleteAccount, updateAccount } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: Acc[]; prefixPattern: string };
type SortDir = "asc" | "desc";
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] {
return [...rows].sort((a, b) => {
const ap = a.username.startsWith(prefix);
const bp = b.username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
return dir === "asc"
? a.username.localeCompare(b.username)
: b.username.localeCompare(a.username);
});
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
wait: { bg: "bg-amber-100", fg: "text-amber-700", label: "wait" },
done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" },
};
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
return (
<span
className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
>
{v.label}
</span>
);
}
function DeleteButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Delete ${label}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</button>
);
}
export default function AccountsTable({ initial, prefixPattern }: Props) {
const router = useRouter();
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial,
(state, patch) =>
state.map((row) =>
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
),
);
const sorted = useMemo(
() => sortAccounts(optimistic, sortDir, prefixPattern),
[optimistic, sortDir, prefixPattern],
);
function saveCell(username: string, field: keyof Acc, value: string) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ username, field, value });
const row = initial.find((r) => r.username === username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
});
});
}
function refresh() {
setRefreshing(true);
startTransition(() => {
router.refresh();
setTimeout(() => setRefreshing(false), 400);
});
}
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteAccount(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
}
if (initial.length === 0) {
return (
<div>
<PageHead
count={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
</p>
</div>
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
prefixPattern={prefixPattern}
/>
</div>
);
}
return (
<div>
<PageHead
count={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
{/* Desktop / tablet table */}
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<button
type="button"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
>
Username
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
</button>
</th>
<Th>Password</Th>
<Th className="w-[16%]">Status</Th>
<Th>Link</Th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
{row.username}
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
/>
</div>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-semibold text-zinc-900">
{row.username}
</span>
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<DeleteButton
label={row.username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.username);
}}
/>
</div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<CardRow label="Password">
<EditableCell
value={row.password}
label={`password for ${row.username}`}
isCurrentlyEditing={editingKey === k("password")}
onEditStart={() => setEditingKey(k("password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "password", v)}
/>
</CardRow>
<CardRow label="Status">
<EditableCell
value={row.status}
label={`status for ${row.username}`}
isCurrentlyEditing={editingKey === k("status")}
onEditStart={() => setEditingKey(k("status"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "status", v)}
/>
</CardRow>
<CardRow label="Link">
<EditableCell
value={row.link}
label={`link for ${row.username}`}
isCurrentlyEditing={editingKey === k("link")}
onEditStart={() => setEditingKey(k("link"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.username, "link", v)}
/>
</CardRow>
</dl>
</div>
);
})}
</div>
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `Account ${name} created` })
}
prefixPattern={prefixPattern}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the accounts table. This action cannot be undone.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<th
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
>
{children}
</th>
);
}
function CardRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function PageHead({
count,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Table
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
</span>
</h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
/**
* Mounts a setInterval that calls router.refresh() every `intervalMs`.
* router.refresh() re-runs the matching Server Component fetch and
* patches the rendered output in no full page reload, no flicker.
*
* Renders nothing.
*/
export default function AutoRefresh({
intervalMs = 30_000,
}: {
intervalMs?: number;
}) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [router, intervalMs]);
return null;
}

View File

@ -0,0 +1,88 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
title: string;
message: React.ReactNode;
confirmLabel?: string;
destructive?: boolean;
pending?: boolean;
};
/**
* Centered modal confirmation dialog. Uses the native <dialog> element
* so we get Esc-to-close, focus trapping, and the ::backdrop pseudo for
* the scrim no a11y tax we'd pay rolling our own. Backdrop click
* cancels.
*/
export default function ConfirmDialog({
open,
onCancel,
onConfirm,
title,
message,
confirmLabel = "Confirm",
destructive = false,
pending = false,
}: Props) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={ref}
onClose={onCancel}
onClick={(e) => {
// Click on the dialog background (not the inner form) cancels.
if (e.target === ref.current) onCancel();
}}
className="m-auto w-[min(92vw,440px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm"
>
<form
method="dialog"
onSubmit={(e) => {
e.preventDefault();
onConfirm();
}}
className="flex flex-col gap-4 p-6"
>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">
{title}
</h2>
<div className="text-sm leading-relaxed text-zinc-600">{message}</div>
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="rounded-full px-4 py-2 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className={`rounded-full px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-60 ${
destructive ? "bg-red-600 hover:bg-red-700" : "bg-zinc-900 hover:bg-zinc-700"
}`}
>
{pending ? "…" : confirmLabel}
</button>
</div>
</form>
</dialog>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { createAccount } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
onSuccess?: (username: string) => void;
prefixPattern?: string;
};
export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPattern }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState("");
const [link, setLink] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
// Reset on open.
useEffect(() => {
if (open) {
setUsername("");
setPassword("");
setStatus("");
setLink("");
setError(null);
// Autofocus the first field only on devices with a fine pointer
// (desktop). Phones skip this — the soft keyboard popping the
// moment a dialog opens is jarring and reflows the page.
if (typeof window !== "undefined") {
const isPointerDevice = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (isPointerDevice) {
requestAnimationFrame(() => firstFieldRef.current?.focus());
}
}
}
}, [open]);
function submit() {
if (!username.trim() || !password) {
setError("Username and password are required");
return;
}
setError(null);
const trimmedUsername = username.trim();
startTransition(async () => {
const result = await createAccount({
username: trimmedUsername,
password,
status,
link,
});
if (result.ok) {
onSuccess?.(trimmedUsername);
onClose();
} else {
setError(result.error);
}
});
}
return (
<FormDialogShell
open={open}
onCancel={onClose}
onSubmit={submit}
title="New account"
submitLabel="Create"
pending={pending}
error={error}
>
<Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}>
<input
ref={firstFieldRef}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder={prefixPattern ? `${prefixPattern}1234` : "username"}
className={inputClass}
/>
</Field>
<Field label="Password" required>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="Status" hint="Empty | wait | done">
<input
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder="(leave blank for available)"
className={inputClass}
/>
</Field>
<Field label="Link">
<input
value={link}
onChange={(e) => setLink(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder="https://..."
className={inputClass}
/>
</Field>
</FormDialogShell>
);
}

View File

@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { createUser } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
onSuccess?: (fUsername: string) => void;
};
export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [fUsername, setFUsername] = useState("");
const [fPassword, setFPassword] = useState("");
const [tUsername, setTUsername] = useState("");
const [tPassword, setTPassword] = useState("");
const firstFieldRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setFUsername("");
setFPassword("");
setTUsername("");
setTPassword("");
setError(null);
// Autofocus first field only on pointer devices — see CreateAccountDialog.
if (typeof window !== "undefined") {
const isPointerDevice = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (isPointerDevice) {
requestAnimationFrame(() => firstFieldRef.current?.focus());
}
}
}
}, [open]);
function submit() {
if (!fUsername.trim() || !fPassword || !tUsername.trim() || !tPassword) {
setError("All fields are required");
return;
}
setError(null);
const trimmedFUsername = fUsername.trim();
startTransition(async () => {
const result = await createUser({
f_username: trimmedFUsername,
f_password: fPassword,
t_username: tUsername.trim(),
t_password: tPassword,
});
if (result.ok) {
onSuccess?.(trimmedFUsername);
onClose();
} else {
setError(result.error);
}
});
}
return (
<FormDialogShell
open={open}
onCancel={onClose}
onSubmit={submit}
title="New user pairing"
submitLabel="Create"
pending={pending}
error={error}
>
<Field label="From username" required>
<input
ref={firstFieldRef}
value={fUsername}
onChange={(e) => setFUsername(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="From password" required>
<input
value={fPassword}
onChange={(e) => setFPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="To username" required>
<input
value={tUsername}
onChange={(e) => setTUsername(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="To password (security PIN)" required>
<input
value={tPassword}
onChange={(e) => setTPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
</FormDialogShell>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
type EditableCellProps = {
value: string;
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
label?: string;
isCurrentlyEditing?: boolean;
onEditStart?: () => void;
onEditEnd?: () => void;
};
export default function EditableCell({
value,
onSave,
label,
isCurrentlyEditing,
onEditStart,
onEditEnd,
}: EditableCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
useEffect(() => {
if (!error) return;
const id = setTimeout(() => setError(null), 3000);
return () => clearTimeout(id);
}, [error]);
function begin() {
setDraft(value);
setEditing(true);
onEditStart?.();
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function cancel() {
setEditing(false);
setDraft(value);
setError(null);
onEditEnd?.();
}
function commit() {
if (draft === value) {
cancel();
return;
}
startTransition(async () => {
const result = await onSave(draft);
if (result.ok) {
setEditing(false);
setError(null);
onEditEnd?.();
} else {
setError(result.error ?? "Save failed");
}
});
}
if (!editing) {
return (
<button
type="button"
onClick={begin}
aria-label={label ? `Edit ${label}` : undefined}
className="group flex w-full min-w-0 items-start gap-2 -mx-2 rounded-md px-2 py-1 text-left font-mono text-[13px] text-zinc-900 transition-colors hover:bg-zinc-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
>
<span className="min-w-0 flex-1 break-all">
{value || <em className="not-italic text-zinc-400"></em>}
</span>
<span
aria-hidden="true"
className="hidden shrink-0 self-center text-[10px] font-medium uppercase tracking-wider text-zinc-400 group-hover:inline"
>
edit
</span>
</button>
);
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
disabled={isPending}
className="w-full min-w-0 rounded-md border-0 bg-zinc-100 px-2 py-1 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]"
/>
<button
type="button"
onClick={commit}
disabled={isPending}
aria-label="Save"
className="shrink-0 rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
>
{isPending ? "…" : "Save"}
</button>
<button
type="button"
onClick={cancel}
disabled={isPending}
aria-label="Cancel"
className="shrink-0 rounded-md px-2.5 py-1 text-[11px] font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
Cancel
</button>
</div>
{error && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Shared <dialog>-based modal shell. Owners pass form contents and a
* submit handler; this component handles open/close imperatively, Esc
* cancellation, and backdrop click cancellation. Native <dialog> gives
* us focus trapping and ::backdrop styling for free.
*/
export default function FormDialogShell({
open,
onCancel,
onSubmit,
title,
children,
cancelLabel = "Cancel",
submitLabel = "Save",
destructive = false,
pending = false,
error,
}: {
open: boolean;
onCancel: () => void;
onSubmit: () => void;
title: string;
children: React.ReactNode;
cancelLabel?: string;
submitLabel?: string;
destructive?: boolean;
pending?: boolean;
error?: string | null;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
onClose={() => {
if (!pending) onCancel();
}}
onClick={(e) => {
if (pending) return;
if (e.target === ref.current) onCancel();
}}
className="m-auto w-[min(92vw,480px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm"
>
<form
method="dialog"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="flex flex-col gap-4 p-6"
>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">{title}</h2>
<div className="flex flex-col gap-3">{children}</div>
{error && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="rounded-full px-4 py-2 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="submit"
disabled={pending}
className={`rounded-full px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-60 ${
destructive
? "bg-red-600 hover:bg-red-700"
: "bg-zinc-900 hover:bg-zinc-700"
}`}
>
{pending ? "…" : submitLabel}
</button>
</div>
</form>
</dialog>
);
}
/**
* A labelled text input that uses 16px font-size on phones (preventing
* iOS Safari auto-zoom-on-focus) and falls back to a tighter 13px on
* tablets/desktop where there's no auto-zoom behavior.
*/
export function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
{children}
{hint && <span className="text-[11px] text-zinc-500">{hint}</span>}
</label>
);
}
export const inputClass =
"w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]";

75
web/components/nav.tsx Normal file
View File

@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function Nav() {
const pathname = usePathname() ?? "/";
const isUsers = pathname.startsWith("/users");
const isAccounts = !isUsers;
return (
<header
className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/80 backdrop-blur-md"
style={{
// Push content below the iPhone notch when the PWA is installed.
// No-op on browsers without a notch (env() resolves to 0).
paddingTop: "env(safe-area-inset-top)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
}}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<Link href="/" className="group flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-[11px] font-semibold tracking-tight text-white">
CM
</span>
<span className="hidden flex-col leading-none sm:flex">
<span className="text-sm font-semibold tracking-tight text-zinc-900">
CM Bot V2
</span>
<span className="mt-0.5 text-[11px] text-zinc-500">
Account dashboard
</span>
</span>
</Link>
<nav
aria-label="Primary"
className="flex items-center gap-1 rounded-full bg-zinc-100 p-1"
>
<NavLink href="/" active={isAccounts}>
Accounts
</NavLink>
<NavLink href="/users" active={isUsers}>
Users
</NavLink>
</nav>
</div>
</header>
);
}
function NavLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`inline-flex items-center rounded-full px-4 py-1.5 text-xs font-medium transition-colors sm:text-sm ${
active
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200/60"
: "text-zinc-500 hover:text-zinc-900"
}`}
>
{children}
</Link>
);
}

63
web/components/toast.tsx Normal file
View File

@ -0,0 +1,63 @@
"use client";
import { useEffect } from "react";
export type ToastMessage = {
type: "success" | "error";
message: string;
};
/**
* Simple top-centered toast. Auto-dismisses after `durationMs` (default
* 3s). Owners hold the ToastMessage state; this component reads it and
* calls onDismiss when the timer fires (or when the toast object
* changes useEffect's cleanup clears any in-flight timer).
*/
export default function Toast({
toast,
onDismiss,
durationMs = 3000,
}: {
toast: ToastMessage | null;
onDismiss: () => void;
durationMs?: number;
}) {
useEffect(() => {
if (!toast) return;
const id = setTimeout(onDismiss, durationMs);
return () => clearTimeout(id);
}, [toast, onDismiss, durationMs]);
if (!toast) return null;
const styles =
toast.type === "success"
? "bg-emerald-50 text-emerald-800 ring-emerald-200"
: "bg-red-50 text-red-800 ring-red-200";
return (
<div
role="status"
aria-live="polite"
className="fixed left-1/2 z-50 -translate-x-1/2 transform px-4"
style={{
// Stay below the notch when running as an installed PWA.
// calc(safe-area + 1rem) keeps the toast 1rem below the safe-area
// edge — and 1rem below the top in browsers without a notch.
top: "calc(env(safe-area-inset-top) + 1rem)",
}}
>
<div
className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`}
>
<span
aria-hidden="true"
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-current/10 text-xs font-bold"
>
{toast.type === "success" ? "✓" : "!"}
</span>
<span className="text-sm font-medium">{toast.message}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,441 @@
"use client";
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { User } from "@/lib/types";
import { deleteUser, updateUser } from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: User[]; prefixPattern: string };
type SortDir = "asc" | "desc";
type SortKey = "f_username" | "last_update_time";
type OptimisticPatch = {
f_username: string;
field: keyof Pick<User, "f_password" | "t_username" | "t_password">;
value: string;
};
function timeOf(t: string | null) {
if (!t) return 0;
const ms = Date.parse(t);
return Number.isNaN(ms) ? 0 : ms;
}
function sortUsers(rows: User[], key: SortKey, dir: SortDir, prefix: string): User[] {
return [...rows].sort((a, b) => {
const ap = a.f_username.startsWith(prefix);
const bp = b.f_username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
if (key === "f_username") {
return dir === "asc"
? a.f_username.localeCompare(b.f_username)
: b.f_username.localeCompare(a.f_username);
}
return dir === "asc"
? timeOf(a.last_update_time) - timeOf(b.last_update_time)
: timeOf(b.last_update_time) - timeOf(a.last_update_time);
});
}
function formatTime(t: string | null) {
if (!t) return <em className="not-italic text-zinc-400"></em>;
const d = new Date(t);
if (Number.isNaN(d.getTime())) return t;
return d.toLocaleString(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function DeleteButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={`Delete ${label}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</button>
);
}
export default function UsersTable({ initial, prefixPattern }: Props) {
const router = useRouter();
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial,
(state, patch) =>
state.map((row) =>
row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row,
),
);
const sorted = useMemo(
() => sortUsers(optimistic, sortKey, sortDir, prefixPattern),
[optimistic, sortKey, sortDir, prefixPattern],
);
function saveCell(
f_username: string,
field: OptimisticPatch["field"],
value: string,
) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ f_username, field, value });
const row = initial.find((r) => r.f_username === f_username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next = {
f_username: row.f_username,
f_password: row.f_password,
t_username: row.t_username,
t_password: row.t_password,
[field]: value,
};
const result = await updateUser(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
});
});
}
function refresh() {
setRefreshing(true);
startTransition(() => {
router.refresh();
setTimeout(() => setRefreshing(false), 400);
});
}
function toggleSort(k: SortKey) {
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(k);
setSortDir("desc");
}
}
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
const result = await deleteUser(deleteTarget);
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` });
} else {
setDeleteError(result.error);
}
}
function HeaderTh({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k;
return (
<button
type="button"
onClick={() => toggleSort(k)}
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
}`}
>
{label}
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span>
</button>
);
}
if (initial.length === 0) {
return (
<div>
<PageHead
count={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
</p>
</div>
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
</div>
);
}
return (
<div>
<PageHead
count={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse">
<thead>
<tr className="bg-zinc-50/60">
<th className="w-[18%] px-5 py-3 text-left">
<HeaderTh k="f_username" label="From username" />
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
From password
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
To username
</th>
<th className="w-[18%] px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500">
To password
</th>
<th className="px-5 py-3 text-left">
<HeaderTh k="last_update_time" label="Last update" />
</th>
<th className="w-12 px-3 py-3" aria-hidden="true" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
{row.f_username}
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.f_password}
label={`from password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("f_password")}
onEditStart={() => setEditingKey(k("f_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_username}
label={`to username for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_username")}
onEditStart={() => setEditingKey(k("t_username"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)}
/>
</td>
<td className="px-5 py-3 align-middle">
<EditableCell
value={row.t_password}
label={`to password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_password")}
onEditStart={() => setEditingKey(k("t_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)}
/>
</td>
<td className="px-5 py-3 align-middle text-xs text-zinc-500">
{formatTime(row.last_update_time)}
</td>
<td className="px-3 py-3 text-right align-middle">
<DeleteButton
label={row.f_username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.f_username);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-base font-semibold text-zinc-900">
{row.f_username}
</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-zinc-500">
{formatTime(row.last_update_time)}
</span>
<DeleteButton
label={row.f_username}
onClick={() => {
setDeleteError(null);
setDeleteTarget(row.f_username);
}}
/>
</div>
</div>
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
<MobileRow label="From PW">
<EditableCell
value={row.f_password}
label={`from password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("f_password")}
onEditStart={() => setEditingKey(k("f_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "f_password", v)}
/>
</MobileRow>
<MobileRow label="To User">
<EditableCell
value={row.t_username}
label={`to username for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_username")}
onEditStart={() => setEditingKey(k("t_username"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_username", v)}
/>
</MobileRow>
<MobileRow label="To PW">
<EditableCell
value={row.t_password}
label={`to password for ${row.f_username}`}
isCurrentlyEditing={editingKey === k("t_password")}
onEditStart={() => setEditingKey(k("t_password"))}
onEditEnd={() => setEditingKey(null)}
onSave={(v) => saveCell(row.f_username, "t_password", v)}
/>
</MobileRow>
</dl>
</div>
);
})}
</div>
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
<ConfirmDialog
open={!!deleteTarget}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDelete}
title={`Delete ${deleteTarget ?? ""}?`}
message={
<>
<p>
Permanently remove the user pairing for{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
{deleteTarget}
</code>{" "}
from the users table. This action cannot be undone and only
removes the pairing the underlying account row stays.
</p>
{deleteError && (
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
{deleteError}
</p>
)}
</>
}
confirmLabel="Delete"
destructive
pending={deleting}
/>
</div>
);
}
function MobileRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function PageHead({
count,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
</h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
>
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div>
);
}

34
web/lib/api.ts Normal file
View File

@ -0,0 +1,34 @@
import type { Acc, User } from "./types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
type FetchInit = {
method?: "GET" | "POST";
body?: unknown;
cache?: RequestCache;
};
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
method: options.method ?? "GET",
cache: options.cache ?? "no-store",
headers: options.body ? { "content-type": "application/json" } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
};
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
}
return res.json();
}
export async function getAccounts(): Promise<Acc[]> {
const data = await fetchApi("/acc/");
return data as Acc[];
}
export async function getUsers(): Promise<User[]> {
const data = await fetchApi("/user/");
return data as User[];
}

24
web/lib/types.ts Normal file
View File

@ -0,0 +1,24 @@
// Mirrors the SQL schema in docker/mysql/init.d/01-schema.sql and the
// JSON projection from app/cm_api.py's get_account / get_user routes.
export type Acc = {
username: string;
password: string;
status: string;
link: string;
};
export type User = {
f_username: string;
f_password: string;
t_username: string;
t_password: string;
last_update_time: string | null;
};
export type AccUpdate = Acc;
export type UserUpdate = Pick<
User,
"f_username" | "f_password" | "t_username" | "t_password"
>;

5
web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
web/next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
trailingSlash: true,
};
export default config;

24
web/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "cm-web-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.2"
}
}

7
web/postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

0
web/public/.gitkeep Normal file
View File

21
web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}