Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/SCOPE_OF_WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Bitcoin Core RPC (port 8332, localhost only)
| `middleware.py` | Security headers, CORS, auth + rate limiting middleware, gzip compression | Middleware chain |
| `exceptions.py` | RPC, validation, HTTP, and generic exception handlers; RFC 7807 `type` URIs | Exception handler registry |
| `jobs.py` | Background fee collector thread lifecycle | Background worker |
| `static_routes.py` | Landing page, robots.txt, sitemap, decision pages | Static file serving |
| `static_routes.py` | Landing page, robots.txt, sitemap, LLM discovery redirects, decision pages | Static file serving |
| `usage_buffer.py` | Batch usage logging (flush at 50 rows or 30s) | Write-behind buffer |
| `migrations/` | SQL migration files + runner, tracked in `schema_migrations` | Sequential migrations |
| `auth.py` | API key validation, tier resolution | Strategy (tier-based) |
Expand All @@ -77,7 +77,7 @@ Bitcoin Core RPC (port 8332, localhost only)

## 3. API Surface

### 3.1 Endpoints (~127 total: 86 core + 3 observatory + 4 AI + 6 alerts + 7 history API + 14 content pages + 4 indexer + 3 x402)
### 3.1 Endpoints (~128 total: 86 core + 3 observatory + 4 AI + 6 alerts + 7 history API + 14 content pages + 1 discovery redirect + 4 indexer + 3 x402)

| Category | Endpoint | Method | Auth Required |
|----------|----------|--------|---------------|
Expand Down Expand Up @@ -192,6 +192,7 @@ Bitcoin Core RPC (port 8332, localhost only)
| | `/guide` | GET | No |
| | `/mcp-setup` | GET | No |
| | `/api-docs` | GET | No |
| **Discovery Redirects** | `/.well-known/llms.txt` | GET/HEAD | No; 301 to `/llms.txt` |
| **AI** | `/api/v1/ai/explain/transaction/{txid}` | GET | No |
| | `/api/v1/ai/explain/block/{hash_or_height}` | GET | No |
| | `/api/v1/ai/fees/advice` | GET | No |
Expand Down Expand Up @@ -477,7 +478,7 @@ Errors follow the same structure:
| 27 | Blockchain indexer Phase 1: PostgreSQL-backed address history, tx lookup, sync worker with ZMQ/polling, reorg handling, address_summary denormalization. Siloed under `indexer/` with `ENABLE_INDEXER=true` in production. Optional deps: asyncpg, pyzmq. | 50 |
| 28 | Analytics automation: referrer tracking endpoint, conversion funnel endpoint, UTM param capture on registration (migration 009), IndexNow auto-submit on deploy, daily analytics digest script, static route fix for IndexNow key file | 5 |
| 29 | RPC proxy endpoint: `/api/v1/rpc` JSON-RPC proxy for bitcoin-mcp zero-config fallback. 30+ whitelisted read-only methods, wallet/admin methods blocked. Enables bitcoin-mcp to work without a local node. | 7 |
| 30 | History Explorer + content pages: `/history` (timeline/block/tx/address pages), 7 history API endpoints (events, eras, concepts, search), `/guide` (Protocol Guide + API catalog), `/mcp-setup` (MCP setup guide), `/api-docs` (branded API docs). Feature flag: `enable_history_explorer`. Updated llms.txt/llms-full.txt with MCP config blocks. MCP server card → v0.5.0. Nav links `/docs` → `/api-docs`. Updated sitemap.xml. | 45 |
| 30 | History Explorer + content pages: `/history` (timeline/block/tx/address pages), 7 history API endpoints (events, eras, concepts, search), `/guide` (Protocol Guide + API catalog), `/mcp-setup` (MCP setup guide), `/api-docs` (branded API docs). Feature flag: `enable_history_explorer`. Updated llms.txt/llms-full.txt with MCP config blocks and `/.well-known/llms.txt` redirect. MCP server card → v0.5.0. Nav links `/docs` → `/api-docs`. Updated sitemap.xml. | 46 |
| 31 | PSBT security analysis: `POST /api/v1/psbt/analyze` — pure-Python BIP 174 PSBT parser detecting ordinals inscription listing mempool sniping vulnerability. Classifies each input's sighash type, detects 2-of-2 multisig protection, returns overall risk level (vulnerable/protected/not_inscription_listing/unknown) + remediation guidance. Feature-flagged off by default (`enable_psbt_router`). No node required. | 24 |
| 32 | Founder analytics dashboard: `GET /api/v1/analytics/founder` (noise-filtered real-user metrics), `GET /admin/founder` (static HTML dashboard), `static/founder-dashboard.html`. Migration 010 (`010_add_signup_attribution.sql`): 9 new columns on `api_keys` for first-touch UTM attribution (`utm_term`, `utm_content`, `first_landing_path`, `first_referrer`, `first_utm_*`). | 4 |
| 33 | Fee Observatory integration: 3 new endpoints (`/fees/observatory/scoreboard`, `/block-stats`, `/estimates`), `fee-observatory` static page (iframe embed), read-only observatory.db access, feature flag `enable_observatory`. | 13 |
Expand Down
116 changes: 93 additions & 23 deletions src/bitcoin_api/static_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from fastapi import Cookie, FastAPI, Header, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse, Response

from . import __version__

_STATIC_DIR = Path(__file__).resolve().parent.parent.parent / "static"
_LANDING_PAGE = _STATIC_DIR / "index.html"
_404_PAGE = _STATIC_DIR / "404.html"
Expand All @@ -24,13 +22,22 @@ def _render_html(path: Path) -> HTMLResponse | None:
from .config import settings

html = path.read_text(encoding="utf-8")
ph_key = settings.posthog_api_key.get_secret_value() if settings.posthog_api_key else ""
ph_key = (
settings.posthog_api_key.get_secret_value() if settings.posthog_api_key else ""
)
html = html.replace("__POSTHOG_API_KEY__", ph_key)
if '/static/js/site-helpers.js' not in html:
if "/static/js/site-helpers.js" not in html:
helper_tag = '<script src="/static/js/site-helpers.js"></script>'
html = re.sub(r"</body>", helper_tag + "\n</body>", html, count=1, flags=re.IGNORECASE)
html = re.sub(
r"</body>", helper_tag + "\n</body>", html, count=1, flags=re.IGNORECASE
)
nonce = secrets.token_urlsafe(16)
html = re.sub(r"<script(?![^>]*\bnonce=)", f'<script nonce="{nonce}"', html, flags=re.IGNORECASE)
html = re.sub(
r"<script(?![^>]*\bnonce=)",
f'<script nonce="{nonce}"',
html,
flags=re.IGNORECASE,
)

# Keep public navigation safe when the History Explorer is disabled.
if not settings.enable_history_explorer:
Expand All @@ -54,6 +61,7 @@ def register_static_routes(app: FastAPI):
"""Register landing page, robots.txt, sitemap, healthz, and static decision pages."""

from starlette.staticfiles import StaticFiles

_js_dir = _STATIC_DIR / "js"
if _js_dir.is_dir():
app.mount("/static/js", StaticFiles(directory=str(_js_dir)), name="static-js")
Expand All @@ -69,18 +77,28 @@ def api_docs_redirect():
"""Avoid maintaining a second docs surface; send users to live Swagger docs."""
return RedirectResponse(url="/docs", status_code=308)

@app.api_route("/.well-known/mcp/server-card.json", methods=_PUBLIC_METHODS, include_in_schema=False)
@app.api_route(
"/.well-known/mcp/server-card.json",
methods=_PUBLIC_METHODS,
include_in_schema=False,
)
def mcp_server_card():
"""MCP server card for Smithery discovery."""
p = _STATIC_DIR / ".well-known" / "mcp" / "server-card.json"
if p.exists():
import json
return Response(
p.read_text(encoding="utf-8"),
media_type="application/json",
)
return Response(status_code=404)

@app.api_route(
"/.well-known/llms.txt", methods=_PUBLIC_METHODS, include_in_schema=False
)
def well_known_llms_txt():
"""Standard .well-known path for LLM discovery redirects to /llms.txt."""
return RedirectResponse(url="/llms.txt", status_code=301)

@app.api_route("/favicon.ico", methods=_PUBLIC_METHODS, include_in_schema=False)
def favicon():
p = _STATIC_DIR / "favicon.ico"
Expand Down Expand Up @@ -116,9 +134,16 @@ def sitemap_xml():
return Response(p.read_text(encoding="utf-8"), media_type="application/xml")
return _serve_404()

_IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp"}
_IMAGE_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
}

@app.api_route("/{filename}.{ext}", methods=_PUBLIC_METHODS, include_in_schema=False)
@app.api_route(
"/{filename}.{ext}", methods=_PUBLIC_METHODS, include_in_schema=False
)
def static_asset(filename: str, ext: str):
"""Serve static assets (images + IndexNow verification key) from the static directory."""
# Prevent path traversal
Expand Down Expand Up @@ -154,16 +179,26 @@ def admin_dashboard(
):
"""Admin analytics dashboard — accepts X-Admin-Key header, admin_token cookie, or ?key= query param."""
from .config import settings

if not settings.admin_api_key:
raise HTTPException(status_code=403, detail="Admin not configured")
resolved_key = _check_admin_key(key, x_admin_key, admin_token)
if not resolved_key or not secrets.compare_digest(resolved_key, settings.admin_api_key.get_secret_value()):
if not resolved_key or not secrets.compare_digest(
resolved_key, settings.admin_api_key.get_secret_value()
):
raise HTTPException(status_code=403, detail="Invalid admin key")
p = _STATIC_DIR / "admin-dashboard.html"
response = _render_html(p) or _serve_404()
# Set cookie so subsequent page loads don't need the key in the URL
if isinstance(response, HTMLResponse) and not admin_token:
response.set_cookie("admin_token", resolved_key, httponly=True, secure=True, samesite="strict", max_age=86400)
response.set_cookie(
"admin_token",
resolved_key,
httponly=True,
secure=True,
samesite="strict",
max_age=86400,
)
return response

@app.get("/admin/founder", include_in_schema=False)
Expand All @@ -174,15 +209,25 @@ def founder_dashboard(
):
"""Founder analytics dashboard — accepts X-Admin-Key header, admin_token cookie, or ?key= query param."""
from .config import settings

if not settings.admin_api_key:
raise HTTPException(status_code=403, detail="Admin not configured")
resolved_key = _check_admin_key(key, x_admin_key, admin_token)
if not resolved_key or not secrets.compare_digest(resolved_key, settings.admin_api_key.get_secret_value()):
if not resolved_key or not secrets.compare_digest(
resolved_key, settings.admin_api_key.get_secret_value()
):
raise HTTPException(status_code=403, detail="Invalid admin key")
p = _STATIC_DIR / "founder-dashboard.html"
response = _render_html(p) or _serve_404()
if isinstance(response, HTMLResponse) and not admin_token:
response.set_cookie("admin_token", resolved_key, httponly=True, secure=True, samesite="strict", max_age=86400)
response.set_cookie(
"admin_token",
resolved_key,
httponly=True,
secure=True,
samesite="strict",
max_age=86400,
)
return response

@app.get("/healthz", include_in_schema=False)
Expand All @@ -194,6 +239,7 @@ def healthz():
def history_index():
"""Serve the History Explorer index page (feature-flag gated)."""
from .config import settings

if not settings.enable_history_explorer:
return _serve_404()
p = _HISTORY_DIR / "index.html"
Expand All @@ -203,6 +249,7 @@ def history_index():
def history_page(page: str):
"""Serve History Explorer sub-pages and static assets."""
from .config import settings

if not settings.enable_history_explorer:
return _serve_404()
# Known HTML pages
Expand All @@ -216,8 +263,15 @@ def history_page(page: str):
if not str(resolved).startswith(str(_HISTORY_DIR.resolve())):
return _serve_404()
if resolved.exists() and resolved.suffix in (".json", ".css", ".js"):
media_types = {".json": "application/json", ".css": "text/css", ".js": "application/javascript"}
return Response(resolved.read_text(encoding="utf-8"), media_type=media_types[resolved.suffix])
media_types = {
".json": "application/json",
".css": "text/css",
".js": "application/javascript",
}
return Response(
resolved.read_text(encoding="utf-8"),
media_type=media_types[resolved.suffix],
)
return _serve_404()

@app.api_route("/{page}", methods=_PUBLIC_METHODS, include_in_schema=False)
Expand All @@ -228,15 +282,31 @@ def static_page(page: str):
if page == "mcp":
return RedirectResponse(url="/mcp-setup", status_code=302)
allowed = {
"vs-mempool", "vs-blockcypher", "best-bitcoin-api-for-developers",
"bitcoin-api-for-ai-agents", "self-hosted-bitcoin-api",
"bitcoin-fee-api", "bitcoin-mempool-api", "bitcoin-mcp-setup-guide",
"bitcoin-transaction-fee-calculator", "best-time-to-send-bitcoin",
"bitcoin-fee-estimator", "bitcoin-api-for-trading-bots",
"vs-mempool",
"vs-blockcypher",
"best-bitcoin-api-for-developers",
"bitcoin-api-for-ai-agents",
"self-hosted-bitcoin-api",
"bitcoin-fee-api",
"bitcoin-mempool-api",
"bitcoin-mcp-setup-guide",
"bitcoin-transaction-fee-calculator",
"best-time-to-send-bitcoin",
"bitcoin-fee-estimator",
"bitcoin-api-for-trading-bots",
"how-to-reduce-bitcoin-transaction-fees",
"bitcoin-api-for-aml-compliance",
"terms", "privacy", "disclaimer", "visualizer", "pricing", "about", "guide",
"mcp-setup", "ai", "fees", "x402",
"terms",
"privacy",
"disclaimer",
"visualizer",
"pricing",
"about",
"guide",
"mcp-setup",
"ai",
"fees",
"x402",
}
if page in allowed:
p = _STATIC_DIR / f"{page}.html"
Expand Down
17 changes: 14 additions & 3 deletions tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,16 @@ def test_docs_accessible(client):

def test_api_docs_redirects_to_live_docs(client):
resp = client.get("/api-docs", follow_redirects=False)
assert resp.status_code == 307
assert resp.status_code == 308
assert resp.headers["location"] == "/docs"


def test_well_known_llms_txt_redirects_to_llms_txt(client):
resp = client.get("/.well-known/llms.txt", follow_redirects=False)
assert resp.status_code == 301
assert resp.headers["location"] == "/llms.txt"


def test_envelope_format(client):
with patch("bitcoin_api.routers.status.cached_status") as mock_cached:
mock_status = MagicMock()
Expand Down Expand Up @@ -127,8 +133,13 @@ def test_root_csp_allows_configured_analytics_scripts(client):

def test_health_deep(authed_client):
"""GET /health/deep should return health check data for authenticated users."""
with patch("bitcoin_api.routers.health_deep.get_job_health", return_value={"fee_collector": "ok"}), \
patch("bitcoin_api.routers.health_deep.usage_buffer") as mock_buf:
with (
patch(
"bitcoin_api.routers.health_deep.get_job_health",
return_value={"fee_collector": "ok"},
),
patch("bitcoin_api.routers.health_deep.usage_buffer") as mock_buf,
):
mock_buf.pending_count = 0
resp = authed_client.get("/api/v1/health/deep")
assert resp.status_code == 200
Expand Down