Co-authored-by: Cursor <cursoragent@cursor.com>
15 KiB
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
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
# 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
# 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
@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
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
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
cd backend && PYTHONPATH=. pytest tests/test_items.py -v
-
Step 3: Implement schemas +
itemsservice -
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; ifburn_after_readand not yet burned, return once then setburned=Trueand delete file if any -
Wall:
is_public==True, not burned, not expired, order bycreated_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
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
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
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
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_secondin last 1.0s -
Step 3: Ban check before create/upload
-
Step 4: Tests PASS, commit
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
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 callingrun_once -
Step 4: Commit
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
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 toADMIN_PASSWORD -
Step 3: Implement admin routes from spec
-
Step 4: Commit
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
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:8080for dev -
Step 3: Global 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
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-inputtype textarea -
TTL
el-selectdefault24h -
Switches: burn, link-only
-
Buttons full-width on
xs -
Upload:
el-uploaddrag +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/wallevery 15s; tap navigates to/p/:slug -
Step 5: Manual check at 375px width (browser device mode)
-
Step 6: Commit
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
<img>from/api/items/:slug/file; other types download link -
Step 2: If API returns burn hint / first open, show
el-alertonce -
Step 3: AdminView — password form; store JWT in
sessionStorage; tables for items/bans; touch-friendly row actions -
Step 4: Commit
git commit -m "feat: item detail and admin moderation UI"
Task 10: Serve SPA from FastAPI + Docker Compose
Files:
-
Modify:
backend/app/main.py— mountfrontend/dist, SPA fallback -
Create:
Dockerfile -
Create:
docker-compose.yml -
Create:
.dockerignore -
Create:
.gitignore -
Create:
README.md -
Step 1: Static mount
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:/dataport 8080 -
Step 4: Build and smoke
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
git commit -m "feat: Docker deploy serving SPA and API on :8080"
Task 11: Acceptance pass (including mobile)
- Step 1: Run backend tests
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
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 |