A Flask-based web application for managing areas in the BTC Map ecosystem. Provides an administrative interface for creating, editing, and validating geographical areas with metadata.
- Python 3.11+
- uv package manager
pip install uvuv venv
source .venv/bin/activate
uv syncpython3 main.pyOr via run script: ./run.sh
Production (gunicorn):
gunicorn app:app- Interactive Maps: Leaflet-based map visualization with GeoJSON editing
- Real-time Validation: Client and server-side validation for area data
- RPC Integration: JSON-RPC communication with BTC Map API
- Nostr Authentication: NIP-98 HTTP Auth via NIP-07 browser extensions
- Session Management: signed-cookie session holding display identity only; the BTC Map API is the account authority and keeps all accounts and tokens
- Area Linting: Automated linting rules for area data quality
├── app.py # Flask application (routes, validation, RPC)
├── admin_session.py # Session helpers: token transport, display identity
├── auth.py # Nostr authentication blueprint
├── rpc_errors.py # JSON-RPC error → user-facing message
├── main.py # Application entry point
├── linting.py # Area linting system and cache
├── pyproject.toml # Dependencies (uv)
├── static/
│ ├── css/
│ └── js/
├── templates/
│ ├── base.html
│ ├── login.html
│ ├── select_area.html
│ ├── show_area.html
│ ├── add_area.html
│ ├── profile.html
│ ├── error.html
│ ├── maintenance.html
│ └── components/
└── run.sh
- Base URL:
https://api.btcmap.org - Protocol: JSON-RPC 2.0
- Auth: Per-user RPC token via Bearer header
- Timeout: 20 seconds per request
| Method | Description |
|---|---|
signin |
Exchange credentials for an API key (Bearer token) |
search |
Search areas by query |
get_area |
Retrieve area details |
add_area |
Create new area |
set_area_tag |
Update/add area tag |
set_area_image |
Attach an image to an area |
remove_area_tag |
Remove area tag |
remove_area |
Delete area |
Sign-in happens in the browser against the BTC Map API; this app stores no accounts and no tokens. Two credentials are accepted:
-
BTC Map username + password — the browser mints a Bearer token with
POST {API_BASE_URL}/v4/users/{username}/tokens. -
Nostr (NIP-98) — the browser signs a kind 27235 event for
{API_BASE_URL}/v4/auth/nostrand exchanges it for a Bearer token. The key must already be linked to a BTC Map account: the API auto-creates a role-useraccount for an unknown npub, and this app refuses those at sign-in. Three signer transports are offered side by side in one dialog (opened from the Nostr button), all implemented instatic/js/nostr-signer.js:- a NIP-07 browser extension (Alby, nos2x) signing in the page;
- a NIP-46 signer app via a pasted
bunker://URI; - a NIP-46 signer app paired by QR: the page shows a
nostrconnect://URI to scan (Amber, Opal, nsec.app), with the URI also copyable and linkable for signers on the same device.
A completed signer-app pairing is kept in
localStorage(btcmap_bunker) so the next visit is one click; Forget signer removes it. That record is a credential at rest — it lets this browser ask the signer for signatures, not sign without it.nostr-tools is loaded from jsDelivr on demand, so a CDN failure only disables the signer-app transports; the extension path keeps working.
The token is kept in localStorage, sent as Authorization: Bearer on every
/api/* call, and mirrored into a btcmap_token cookie so server-rendered
pages can authenticate (a navigation cannot carry a header). The Flask session
cookie holds display-only identity and is never used for authorisation.
API_BASE_URL must be absolute and identical to the API's own
BTCMAP_API_BASE_URL, because the NIP-98 u tag signs that exact origin.
GET /login- Login page (credentials, then Nostr)POST /auth/session- Validate a minted Bearer token and start the sessionPOST /auth/logout- End the session
The API decides what an account may edit; this app only reflects that decision.
- Sign-in requires one of
admin,rootorarea_manager(admin_session.AREA_EDIT_ROLES). Everything else is refused with an explanation instead of failing later on an RPC call. area_manageraccounts carry a geofence: the list of area ids they may edit (GET /v4/users/me→geofence). An empty geofence means unrestricted; the API applies a non-empty one to every role, so the app reads the scope from the account and never from the role name.- Reading is unrestricted: any area opens. Editing outside the geofence is
refused by the API (
Area <id> is outside your geofence (allowed areas: …)), so the area page hides its editing controls and says why, search results mark such areas Read-only, and Maintenance disables its lint fixes for them.
The API answers a failed RPC with HTTP 200 and a JSON-RPC error whose
message is usually the generic Server error and whose data carries the
real text. rpc_errors.rpc_error_message() flattens both shapes into one line;
every route returns {"error": "<message>"} and every page shows it through
errorText() in static/js/script.js. A refusal such as an out-of-geofence
edit or a missing permission therefore reaches the user verbatim, and a
get_area refusal renders as an explanation rather than "Area not found".
| Field | Type | Notes |
|---|---|---|
name |
text | Non-empty |
type |
select | Must be "community" |
url_alias |
text | URL-friendly identifier |
continent |
select | africa, asia, europe, north-america, oceania, south-america |
icon:square |
text | Icon identifier |
population |
number | Non-negative integer |
population:date |
date | YYYY-MM-DD format |
geo_json |
GeoJSON | Valid Polygon/MultiPolygon |
Contact fields (contact:website, contact:email, contact:phone, etc.), area_km2 (auto-calculated), organization, language, description, tips:lightning_address.
Validation functions in app.py return (is_valid, error_message). GeoJSON validation uses Shapely for geometry checking and geojson-rewind for correct orientation. Client-side validation in static/js/validation.js mirrors server logic.
LintRule/LintResultdataclasses for rule definitions and issue reportingLintCachefor global area linting- Auto-fix functions in
FIX_ACTIONSdict - Add new rules: define in
LINT_RULES, create check function, add tolint_area()check list
- Python:
snake_casefor functions/variables,PascalCasefor classes, type hints where beneficial - Flask: Clear endpoint names,
app.loggerfor logging, proper HTTP status codes - JS: Vanilla JS, follow existing patterns in
static/js/ - Templates: Jinja2 with Bootstrap 5 classes, Leaflet.js maps via CDN
Core: flask, requests, geojson-rewind, shapely, pyproj; gunicorn for serving