# MincedPad 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:** Build a self-hosted anonymous paste/file share app (MincedPad) with public wall, TTL/burn, admin moderation, Docker deploy, and mobile-usable UI. **Architecture:** FastAPI + SQLite + local uploads in one process; Vue3/Element Plus SPA built into the image and served by FastAPI; in-memory per-IP rate limit; background cleaner for expiry. **Tech Stack:** Python 3.12, FastAPI, SQLAlchemy, SQLite, python-jose/passlib or PyJWT, Vue 3, Vite, Element Plus, TypeScript, Docker multi-stage. **Spec:** `docs/superpowers/specs/2026-07-14-mincedpad-design.md` --- ## File map (create) | Path | Responsibility | | --- | --- | | `backend/app/config.py` | Env settings | | `backend/app/db.py` | Engine/session | | `backend/app/models.py` | ORM models | | `backend/app/schemas.py` | Pydantic schemas | | `backend/app/security.py` | JWT admin auth | | `backend/app/services/storage.py` | File save/delete | | `backend/app/services/rate_limit.py` | 2/sec per IP | | `backend/app/services/bans.py` | Ban checks | | `backend/app/services/items.py` | Item CRUD/burn/expire | | `backend/app/services/cleaner.py` | Periodic cleanup | | `backend/app/api/public.py` | Public routes | | `backend/app/api/admin.py` | Admin routes | | `backend/app/main.py` | App factory + static SPA | | `backend/requirements.txt` | Deps | | `backend/tests/test_items.py` | API tests | | `frontend/` | Vue3 SPA (mobile-first) | | `Dockerfile` | Multi-stage build | | `docker-compose.yml` | Deploy | | `README.md` | Usage | --- ### Task 1: Backend skeleton (config, DB, models) **Files:** - Create: `backend/requirements.txt` - Create: `backend/app/__init__.py` - Create: `backend/app/config.py` - Create: `backend/app/db.py` - Create: `backend/app/models.py` - Create: `backend/app/main.py` (minimal health) - [ ] **Step 1: Add dependencies** ```text fastapi==0.115.6 uvicorn[standard]==0.34.0 sqlalchemy==2.0.36 python-multipart==0.0.20 pyjwt==2.10.1 httpx==0.28.1 pytest==8.3.4 ``` - [ ] **Step 2: Implement config** ```python # backend/app/config.py from pathlib import Path from pydantic_settings import BaseSettings class Settings(BaseSettings): data_dir: Path = Path("/data") admin_password: str = "changeme" secret_key: str = "dev-secret-change-me" max_upload_mb: int = 200 rate_limit_per_second: int = 2 jwt_expire_hours: int = 12 host: str = "0.0.0.0" port: int = 8080 @property def db_url(self) -> str: return f"sqlite:///{self.data_dir / 'mincedpad.db'}" @property def uploads_dir(self) -> Path: return self.data_dir / "uploads" settings = Settings() ``` Also add `pydantic-settings==2.7.0` to requirements. - [ ] **Step 3: DB + models** ```python # backend/app/models.py (essentials) class Item(Base): __tablename__ = "items" id: Mapped[int] = mapped_column(primary_key=True) slug: Mapped[str] = mapped_column(String(32), unique=True, index=True) kind: Mapped[str] = mapped_column(String(16)) # text|file title: Mapped[str] = mapped_column(String(255), default="") body: Mapped[str | None] = mapped_column(Text, nullable=True) file_name: Mapped[str | None] = mapped_column(String(512), nullable=True) file_path: Mapped[str | None] = mapped_column(String(512), nullable=True) mime: Mapped[str | None] = mapped_column(String(128), nullable=True) size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) is_public: Mapped[bool] = mapped_column(Boolean, default=True) burn_after_read: Mapped[bool] = mapped_column(Boolean, default=False) expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_ip: Mapped[str] = mapped_column(String(64), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) view_count: Mapped[int] = mapped_column(Integer, default=0) burned: Mapped[bool] = mapped_column(Boolean, default=False) class BannedIP(Base): __tablename__ = "banned_ips" ip: Mapped[str] = mapped_column(String(64), primary_key=True) reason: Mapped[str] = mapped_column(String(255), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) ``` - [ ] **Step 4: Minimal app with `/api/health`** ```python @app.get("/api/health") def health(): return {"ok": True, "name": "MincedPad"} ``` On startup: `settings.data_dir.mkdir`, `uploads_dir.mkdir`, `Base.metadata.create_all`. - [ ] **Step 5: Commit** ```bash git add backend git commit -m "feat: scaffold FastAPI app, config, and SQLite models" ``` --- ### Task 2: Item service + public create/get/wall APIs **Files:** - Create: `backend/app/schemas.py` - Create: `backend/app/services/items.py` - Create: `backend/app/api/public.py` - Create: `backend/tests/conftest.py` - Create: `backend/tests/test_items.py` - Modify: `backend/app/main.py` — include router - [ ] **Step 1: Write failing tests** ```python def test_create_text_default_public_and_24h(client): r = client.post("/api/items", json={"body": "hello **md**"}) assert r.status_code == 200 data = r.json() assert data["slug"] assert data["is_public"] is True assert data["expires_at"] is not None def test_link_only_not_on_wall(client): r = client.post("/api/items", json={"body": "secret", "is_public": False}) slug = r.json()["slug"] wall = client.get("/api/items/wall").json()["items"] assert all(i["slug"] != slug for i in wall) assert client.get(f"/api/items/{slug}").status_code == 200 ``` - [ ] **Step 2: Run tests — expect FAIL** ```bash cd backend && PYTHONPATH=. pytest tests/test_items.py -v ``` - [ ] **Step 3: Implement schemas + `items` service** - Slug: `secrets.token_urlsafe(8)` trimmed to URL-safe short id - TTL map: `1h`→+1h, `24h`→+24h, `7d`→+7d, `never`→`None` - `get_item`: 404 if missing/expired/`burned`; if `burn_after_read` and not yet burned, return once then set `burned=True` and delete file if any - Wall: `is_public==True`, not burned, not expired, order by `created_at desc` - [ ] **Step 4: Wire `POST /api/items`, `GET /api/items/wall`, `GET /api/items/{slug}`** Client IP from `request.client.host` (and honor `X-Forwarded-For` first hop when present for Docker proxy later). - [ ] **Step 5: Tests PASS, then commit** ```bash git commit -m "feat: public text items, wall, and detail API" ``` --- ### Task 3: File upload, allowlist, download **Files:** - Create: `backend/app/services/storage.py` - Modify: `backend/app/api/public.py` - Modify: `backend/tests/test_items.py` - [ ] **Step 1: Failing tests for upload + size reject** ```python def test_upload_small_file(client, tmp_path): files = {"file": ("hi.txt", b"abc", "text/plain")} data = {"is_public": "true", "ttl": "24h"} r = client.post("/api/items/upload", files=files, data=data) assert r.status_code == 200 slug = r.json()["slug"] f = client.get(f"/api/items/{slug}/file") assert f.status_code == 200 assert f.content == b"abc" def test_reject_oversize(client, monkeypatch): monkeypatch.setenv("MAX_UPLOAD_MB", "1") # re-load settings or override app dependency — use 1MB+ body ... ``` For oversize test, override `settings.max_upload_mb = 1` in fixture if env reload is awkward. - [ ] **Step 2: Implement `storage.save_upload(file) -> (rel_path, size, mime, safe_name)`** Allowlist extensions from spec. Reject others with 400. - [ ] **Step 3: `POST /api/items/upload` + `GET /api/items/{slug}/file`** Use `FileResponse` with correct `media_type`; images `Content-Disposition: inline`. - [ ] **Step 4: Tests PASS, commit** ```bash git commit -m "feat: file upload, allowlist, and download/preview" ``` --- ### Task 4: Rate limit (2/s) + IP bans on writes **Files:** - Create: `backend/app/services/rate_limit.py` - Create: `backend/app/services/bans.py` - Modify: `backend/app/api/public.py` - Modify: `backend/tests/test_items.py` - [ ] **Step 1: Failing tests** ```python def test_rate_limit_third_request_in_same_second(client): # three quick creates → last is 429 ... def test_banned_ip_cannot_create(client, db): db.add(BannedIP(ip="testclient")) ... assert client.post("/api/items", json={"body": "x"}).status_code == 403 ``` Note: Starlette TestClient IP is often `testclient` — assert against that or inject dependency. - [ ] **Step 2: Sliding window: deque of timestamps per IP; allow if `< rate_limit_per_second` in last 1.0s** - [ ] **Step 3: Ban check before create/upload** - [ ] **Step 4: Tests PASS, commit** ```bash git commit -m "feat: per-IP rate limit and write bans" ``` --- ### Task 5: Cleaner + burn-after-read verification **Files:** - Create: `backend/app/services/cleaner.py` - Modify: `backend/app/main.py` — start background task - Modify: `backend/tests/test_items.py` - [ ] **Step 1: Test burn** ```python def test_burn_after_read(client): r = client.post("/api/items", json={"body": "once", "burn_after_read": True, "ttl": "never"}) slug = r.json()["slug"] assert client.get(f"/api/items/{slug}").status_code == 200 assert client.get(f"/api/items/{slug}").status_code == 404 ``` - [ ] **Step 2: Test cleaner removes expired** Create item with `expires_at` in the past via service/DB, run `cleaner.run_once(db)`, assert gone. - [ ] **Step 3: Implement lifespan task `asyncio.sleep(60)` loop calling `run_once`** - [ ] **Step 4: Commit** ```bash git commit -m "feat: burn-after-read and expiry cleaner" ``` --- ### Task 6: Admin auth + moderation APIs **Files:** - Create: `backend/app/security.py` - Create: `backend/app/api/admin.py` - Create: `backend/tests/test_admin.py` - Modify: `backend/app/main.py` - [ ] **Step 1: Tests for login, delete item, ban/unban** ```python def test_admin_login_and_delete(client): token = client.post("/api/admin/login", json={"password": "changeme"}).json()["token"] headers = {"Authorization": f"Bearer {token}"} slug = client.post("/api/items", json={"body": "bye"}).json()["slug"] items = client.get("/api/admin/items", headers=headers).json()["items"] item_id = next(i["id"] for i in items if i["slug"] == slug) assert client.delete(f"/api/admin/items/{item_id}", headers=headers).status_code == 200 assert client.get(f"/api/items/{slug}").status_code == 404 ``` - [ ] **Step 2: JWT create/verify with `SECRET_KEY`; password compare to `ADMIN_PASSWORD`** - [ ] **Step 3: Implement admin routes from spec** - [ ] **Step 4: Commit** ```bash git commit -m "feat: admin login, delete items, and IP bans API" ``` --- ### Task 7: Frontend scaffold (Vue3 + Element Plus, mobile-first) **Files:** - Create: `frontend/package.json`, `vite.config.ts`, `index.html`, `src/main.ts`, `src/App.vue`, `src/router/index.ts`, `src/api/client.ts`, `src/styles.css` - [ ] **Step 1: Scaffold with Vite vue-ts; add `element-plus`, `vue-router`, `markdown-it`** ```bash cd frontend && npm create vite@latest . -- --template vue-ts npm i element-plus vue-router markdown-it npm i -D @types/markdown-it ``` - [ ] **Step 2: Vite proxy `/api` → `http://127.0.0.1:8080` for dev** - [ ] **Step 3: Global CSS** ```css html, body, #app { margin: 0; min-height: 100%; } .page { max-width: 960px; margin: 0 auto; padding: 12px 16px 32px; } @media (max-width: 768px) { .page { padding: 8px 12px 24px; } .desktop-only { display: none !important; } } ``` - [ ] **Step 4: Router routes `/`, `/p/:slug`, `/admin`** - [ ] **Step 5: Viewport meta in `index.html`:** `width=device-width, initial-scale=1, viewport-fit=cover` - [ ] **Step 6: Commit** ```bash git commit -m "feat: scaffold Vue3 Element Plus frontend with mobile base" ``` --- ### Task 8: Home page — composer + wall (responsive) **Files:** - Create: `frontend/src/views/HomeView.vue` - Create: `frontend/src/components/Composer.vue` - Create: `frontend/src/components/WallList.vue` - [ ] **Step 1: Layout with `el-row` / `el-col :xs="24" :md="12"`** — stack on phone - [ ] **Step 2: Composer controls** - Markdown `el-input` type textarea - TTL `el-select` default `24h` - Switches: burn, link-only - Buttons full-width on `xs` - Upload: `el-upload` drag + `accept` + mobile falls back to file picker - [ ] **Step 3: On success show share URL with copy button (`navigator.clipboard`)** - [ ] **Step 4: Wall list cards; poll `GET /api/items/wall` every 15s; tap navigates to `/p/:slug`** - [ ] **Step 5: Manual check at 375px width (browser device mode)** - [ ] **Step 6: Commit** ```bash git commit -m "feat: home composer and public wall UI" ``` --- ### Task 9: Detail + Admin views **Files:** - Create: `frontend/src/views/ItemView.vue` - Create: `frontend/src/views/AdminView.vue` - [ ] **Step 1: ItemView — fetch slug; render markdown via markdown-it; images `` from `/api/items/:slug/file`; other types download link** - [ ] **Step 2: If API returns burn hint / first open, show `el-alert` once** - [ ] **Step 3: AdminView — password form; store JWT in `sessionStorage`; tables for items/bans; touch-friendly row actions** - [ ] **Step 4: Commit** ```bash git commit -m "feat: item detail and admin moderation UI" ``` --- ### Task 10: Serve SPA from FastAPI + Docker Compose **Files:** - Modify: `backend/app/main.py` — mount `frontend/dist`, SPA fallback - Create: `Dockerfile` - Create: `docker-compose.yml` - Create: `.dockerignore` - Create: `.gitignore` - Create: `README.md` - [ ] **Step 1: Static mount** ```python dist = Path(__file__).resolve().parents[2] / "frontend" / "dist" if dist.exists(): app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets") @app.get("/{full_path:path}") async def spa(full_path: str): if full_path.startswith("api/"): raise HTTPException(404) index = dist / "index.html" return FileResponse(index) ``` Order: API routers registered before SPA catch-all. - [ ] **Step 2: Multi-stage Dockerfile** Stage1: `node:20` build frontend Stage2: `python:3.12-slim` install backend, copy dist → `/app/frontend/dist`, CMD uvicorn on 8080 - [ ] **Step 3: docker-compose with env + `./data:/data` port 8080** - [ ] **Step 4: Build and smoke** ```bash docker compose up -d --build curl -s http://127.0.0.1:8080/api/health curl -s -X POST http://127.0.0.1:8080/api/items -H 'content-type: application/json' -d '{"body":"hi"}' ``` - [ ] **Step 5: README — deploy, env vars, mobile note, admin URL** - [ ] **Step 6: Commit** ```bash git commit -m "feat: Docker deploy serving SPA and API on :8080" ``` --- ### Task 11: Acceptance pass (including mobile) - [ ] **Step 1: Run backend tests** ```bash cd backend && PYTHONPATH=. pytest -v ``` Expected: all PASS - [ ] **Step 2: Manual checklist against spec §10 + mobile** - Create text + file on desktop and phone width - Wall vs link-only - TTL / burn - Admin delete + ban - 429 when hammering create - Upload over limit rejected - [ ] **Step 3: Final commit if any polish** ```bash git commit -m "chore: polish for mobile acceptance" ``` --- ## Spec coverage checklist | Spec requirement | Task | | --- | --- | | Anonymous create/share | 2, 3, 8 | | Public wall + link-only | 2, 8 | | Markdown + files | 3, 8, 9 | | TTL default 24h + burn | 2, 5 | | Admin password JWT | 6, 9 | | Rate 2/s, 200MB | 3, 4 | | Docker Compose | 10 | | Mobile usable | 7, 8, 9, 11 |