From 23f716255e036533de0a3a8fe57545ab44e230a5 Mon Sep 17 00:00:00 2001 From: Michilis Date: Fri, 19 Dec 2025 23:56:07 -0300 Subject: [PATCH] Initial commit --- .gitignore | 94 ++ README.md | 418 ++++++ deploy/cashumints-api.service | 64 + deploy/cashumints-workers.service | 63 + deploy/install.sh | 176 +++ deploy/nginx.conf | 160 ++ env.example | 139 ++ package-lock.json | 1675 +++++++++++++++++++++ package.json | 51 + src/config.js | 91 ++ src/db/connection.js | 154 ++ src/db/migrate.js | 21 + src/db/schema-admin.sql | 59 + src/db/schema.sql | 271 ++++ src/docs/openapi.js | 2300 +++++++++++++++++++++++++++++ src/index.js | 290 ++++ src/middleware/adminAuth.js | 97 ++ src/middleware/errorHandler.js | 43 + src/middleware/rateLimit.js | 77 + src/routes/admin.js | 597 ++++++++ src/routes/mints.js | 909 ++++++++++++ src/routes/system.js | 362 +++++ src/services/AdminService.js | 751 ++++++++++ src/services/AnalyticsService.js | 788 ++++++++++ src/services/JobService.js | 209 +++ src/services/MetadataService.js | 358 +++++ src/services/MintService.js | 284 ++++ src/services/PageviewService.js | 325 ++++ src/services/PlausibleService.js | 413 ++++++ src/services/ProbeService.js | 297 ++++ src/services/ReviewService.js | 460 ++++++ src/services/TrustService.js | 462 ++++++ src/services/UptimeService.js | 284 ++++ src/services/index.js | 15 + src/utils/crypto.js | 51 + src/utils/index.js | 10 + src/utils/time.js | 124 ++ src/utils/url.js | 176 +++ src/workers/index.js | 98 ++ src/workers/metadata.js | 102 ++ src/workers/nostr.js | 333 +++++ src/workers/probe.js | 74 + src/workers/rollup.js | 54 + src/workers/trust.js | 52 + starter-docs/admin_endpoints.md | 327 ++++ starter-docs/endpoints.md | 229 +++ starter-docs/full_overview.md | 240 +++ starter-docs/logic.md | 207 +++ 48 files changed, 14834 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/cashumints-api.service create mode 100644 deploy/cashumints-workers.service create mode 100644 deploy/install.sh create mode 100644 deploy/nginx.conf create mode 100644 env.example create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config.js create mode 100644 src/db/connection.js create mode 100644 src/db/migrate.js create mode 100644 src/db/schema-admin.sql create mode 100644 src/db/schema.sql create mode 100644 src/docs/openapi.js create mode 100644 src/index.js create mode 100644 src/middleware/adminAuth.js create mode 100644 src/middleware/errorHandler.js create mode 100644 src/middleware/rateLimit.js create mode 100644 src/routes/admin.js create mode 100644 src/routes/mints.js create mode 100644 src/routes/system.js create mode 100644 src/services/AdminService.js create mode 100644 src/services/AnalyticsService.js create mode 100644 src/services/JobService.js create mode 100644 src/services/MetadataService.js create mode 100644 src/services/MintService.js create mode 100644 src/services/PageviewService.js create mode 100644 src/services/PlausibleService.js create mode 100644 src/services/ProbeService.js create mode 100644 src/services/ReviewService.js create mode 100644 src/services/TrustService.js create mode 100644 src/services/UptimeService.js create mode 100644 src/services/index.js create mode 100644 src/utils/crypto.js create mode 100644 src/utils/index.js create mode 100644 src/utils/time.js create mode 100644 src/utils/url.js create mode 100644 src/workers/index.js create mode 100644 src/workers/metadata.js create mode 100644 src/workers/nostr.js create mode 100644 src/workers/probe.js create mode 100644 src/workers/rollup.js create mode 100644 src/workers/trust.js create mode 100644 starter-docs/admin_endpoints.md create mode 100644 starter-docs/endpoints.md create mode 100644 starter-docs/full_overview.md create mode 100644 starter-docs/logic.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..287fff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +# ============================================ +# Dependencies +# ============================================ +node_modules/ + +# ============================================ +# Database +# ============================================ +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 + +# ============================================ +# Environment & Secrets +# ============================================ +.env +.env.local +.env.*.local +.env.production +.env.staging +*.pem +*.key + +# ============================================ +# Logs +# ============================================ +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ============================================ +# OS Files +# ============================================ +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# ============================================ +# IDE & Editors +# ============================================ +.idea/ +.vscode/ +.history/ +*.swp +*.swo +*.swn +*~ +*.sublime-project +*.sublime-workspace + +# ============================================ +# Build & Output +# ============================================ +dist/ +build/ +out/ +.next/ +.nuxt/ + +# ============================================ +# Test & Coverage +# ============================================ +coverage/ +.nyc_output/ +*.lcov +.coverage + +# ============================================ +# Temporary Files +# ============================================ +tmp/ +temp/ +*.tmp +*.temp +*.bak + +# ============================================ +# Runtime +# ============================================ +pids/ +*.pid +*.seed +*.pid.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..7daa243 --- /dev/null +++ b/README.md @@ -0,0 +1,418 @@ +# 🌿 Cashumints.space API + +A production-ready, decentralized observability, discovery, and reputation API for the Cashu mint ecosystem. + +## Features + +- **Mint Discovery** - Track and index Cashu mints across the ecosystem +- **Uptime Monitoring** - Continuous probing with full historical data +- **Metadata Tracking** - NUT-06 compliant metadata with version history +- **Nostr Reviews** - NIP-87 based review ingestion from configurable relays +- **Trust Scores** - Transparent, explainable reputation scoring (0-100) +- **Analytics** - Pageviews, trending mints, and popularity metrics +- **Admin API** - Full mint curation and system management +- **OpenAPI Docs** - Interactive Swagger UI documentation + +## Quick Start + +```bash +# Clone and install +git clone +cd cashumints-api +npm install + +# Configure +cp env.example .env +openssl rand -hex 32 # Generate admin key, add to .env + +# Initialize database +npm run migrate + +# Start API server +npm start + +# View documentation +open http://localhost:3000/docs +``` + +## Production Deployment + +### Prerequisites + +- Node.js 18+ +- Nginx (reverse proxy) +- systemd (process management) +- Let's Encrypt (SSL) + +### Installation + +```bash +# Create user +sudo useradd -r -s /bin/false cashumints + +# Clone to /opt +sudo git clone /opt/cashumints-api +cd /opt/cashumints-api + +# Install dependencies +sudo npm install --production + +# Configure +sudo cp env.example .env +sudo openssl rand -hex 32 # Add to .env as ADMIN_API_KEY +sudo nano .env + +# Create data directory +sudo mkdir -p data +sudo chown cashumints:cashumints data + +# Initialize database +sudo -u cashumints npm run migrate +``` + +### Systemd Services + +```bash +# Install service files +sudo cp deploy/cashumints-api.service /etc/systemd/system/ +sudo cp deploy/cashumints-workers.service /etc/systemd/system/ + +# Reload and enable +sudo systemctl daemon-reload +sudo systemctl enable cashumints-api cashumints-workers + +# Start services +sudo systemctl start cashumints-api +sudo systemctl start cashumints-workers + +# Check status +sudo systemctl status cashumints-api +sudo systemctl status cashumints-workers + +# View logs +sudo journalctl -u cashumints-api -f +sudo journalctl -u cashumints-workers -f +``` + +### Nginx Setup + +```bash +# Install nginx config +sudo cp deploy/nginx.conf /etc/nginx/sites-available/cashumints-api +sudo ln -s /etc/nginx/sites-available/cashumints-api /etc/nginx/sites-enabled/ + +# Get SSL certificate +sudo certbot --nginx -d api.cashumints.space + +# Test and reload +sudo nginx -t +sudo systemctl reload nginx +``` + +## API Documentation + +Interactive API documentation is available at: + +- **Swagger UI**: `http://localhost:3000/docs` +- **OpenAPI JSON**: `http://localhost:3000/openapi.json` + +## API Endpoints + +Base URL: `/v1` + +### System + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Health check | +| GET | `/stats` | System statistics | +| GET | `/reviews` | Global reviews feed (filterable) | +| GET | `/analytics/uptime` | Ecosystem uptime analytics | +| GET | `/analytics/versions` | Mint version distribution | +| GET | `/analytics/nuts` | NUT support analytics | + +### Admin (requires `X-Admin-Api-Key` header) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/admin/mints` | Manually add a mint | +| POST | `/admin/mints/{id}/urls` | Add URL to mint | +| POST | `/admin/mints/merge` | Merge two mints | +| POST | `/admin/mints/split` | Undo a merge | +| POST | `/admin/mints/{id}/disable` | Hide from listings | +| POST | `/admin/mints/{id}/enable` | Re-enable mint | +| POST | `/admin/mints/{id}/metadata/refresh` | Force metadata fetch | +| POST | `/admin/mints/{id}/trust/recompute` | Force trust recalc | +| POST | `/admin/mints/{id}/status/reset` | Reset stuck state | +| POST | `/admin/mints/{id}/probe` | Force immediate probe | +| GET | `/admin/mints/{id}/visibility` | Get visibility status | +| GET | `/admin/jobs` | View job queue | +| GET | `/admin/system/metrics` | System health | +| GET | `/admin/audit` | Audit log (filter by mint_id) | + +Admin endpoints also support `/admin/mints/by-url?url=` variants for all operations. + +### Mints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/mints` | List all mints (paginated, filterable) | +| GET | `/mints/trending` | Get trending mints by popularity | +| GET | `/mints/{mint_id}` | Get mint by ID | +| GET | `/mints/by-url?url=` | Get mint by URL | +| POST | `/mints/submit` | Submit new mint for tracking | + +### Mint Details + +All endpoints support both `/{mint_id}` and `/by-url?url=` variants: + +| Endpoint | Description | +|----------|-------------| +| `/urls` | All URLs for mint (clearnet, Tor, mirrors) | +| `/metadata` | NUT-06 metadata | +| `/metadata/history` | Metadata change history | +| `/status` | Lightweight current status | +| `/uptime?window=24h\|7d\|30d` | Uptime statistics | +| `/uptime/timeseries?window=&bucket=` | Timeseries for charting | +| `/incidents` | Downtime incidents | +| `/trust` | Trust score with breakdown | +| `/reviews` | Nostr reviews (NIP-87) | +| `/views` | Pageview statistics | +| `/features` | Derived features (NUTs, Bolt11, etc.) | + +## Configuration + +Copy `env.example` to `.env` and configure: + +```bash +# Server +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=production + +# Admin (REQUIRED - generate with: openssl rand -hex 32) +ADMIN_API_KEY=your-secure-key-here + +# Database (SQLite) +DATABASE_PATH=./data/cashumints.db + +# Probing +PROBE_TIMEOUT_MS=10000 +PROBE_INTERVAL_ONLINE_MS=300000 # 5 minutes +PROBE_INTERVAL_OFFLINE_MS=900000 # 15 minutes +CONSECUTIVE_FAILURES_OFFLINE=3 +ABANDONED_AFTER_DAYS=30 + +# Nostr Relays (comma-separated) +NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol + +# Trust Score Weights (out of 100) +TRUST_WEIGHT_UPTIME=40 +TRUST_WEIGHT_SPEED=25 +TRUST_WEIGHT_REVIEWS=20 +TRUST_WEIGHT_IDENTITY=10 +TRUST_PENALTY_MAX=15 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 +``` + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Nginx (Reverse Proxy) β”‚ +β”‚ β€’ SSL termination β€’ Rate limiting β€’ Caching β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HTTP API (Express.js) β”‚ +β”‚ β€’ REST endpoints β€’ Swagger UI β€’ Admin API β”‚ +β”‚ β€’ CORS enabled β€’ Compression β€’ Helmet security β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SQLite Database β”‚ +β”‚ mints β”‚ mint_urls β”‚ probes β”‚ metadata β”‚ reviews β”‚ scores β”‚ +β”‚ incidents β”‚ pageviews β”‚ jobs β”‚ uptime_rollups β”‚ audit_log β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Background Workers β”‚ +β”‚ Probe β”‚ Metadata β”‚ Nostr β”‚ Rollup β”‚ Trust β”‚ Cleanup β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Trust Score + +Trust scores (0-100) are computed from: + +| Component | Max Points | Based On | +|-----------|------------|----------| +| Uptime | 40 | 30-day uptime percentage | +| Speed | 25 | Average response time (RTT) | +| Reviews | 20 | Nostr review quantity & rating | +| Identity | 10 | Metadata completeness | +| Penalties | -15 | Offline status, incidents, instability | + +**Trust Levels:** +- **Excellent**: 85-100 +- **High**: 70-84 +- **Medium**: 50-69 +- **Low**: 25-49 +- **Unknown**: 0-24 + +## Mint Lifecycle + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + Discovery ───▢ β”‚ unknown β”‚ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ successful probe + β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” + β”‚ online β”‚ ◀──────────┐ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ slow response β”‚ recovery + β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ degraded β”‚ ──────────── + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ consecutive β”‚ + β”‚ failures β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ offline β”‚ β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ > 30 days + β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” + β”‚ abandoned β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Nostr Integration (NIP-87) + +The API ingests these event kinds from configured relays: + +| Kind | Description | +|------|-------------| +| 38172 | Cashu mint announcements (self-published by mints) | +| 38173 | Fedimint announcements | +| 38000 | Recommendations/reviews (with `#k` tag referencing 38172) | + +Reviews are parsed for: +- Mint URL (from `u` or `d` tags, with or without protocol) +- Rating 1-5 (from `rating` tag or `quality` tag like `"1/1"` recommend / `"0/1"` not recommend) +- Content (review text) + +## Admin API + +All admin actions are audited. Never deletes raw data. + +```bash +# Add a mint manually +curl -X POST http://localhost:3000/v1/admin/mints \ + -H "X-Admin-Api-Key: $ADMIN_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"mint_url": "https://mint.example.com", "notes": "Trusted operator"}' + +# Get system metrics +curl http://localhost:3000/v1/admin/system/metrics \ + -H "X-Admin-Api-Key: $ADMIN_API_KEY" + +# Force trust recalculation +curl -X POST http://localhost:3000/v1/admin/mints/{mint_id}/trust/recompute \ + -H "X-Admin-Api-Key: $ADMIN_API_KEY" + +# View audit log +curl http://localhost:3000/v1/admin/audit \ + -H "X-Admin-Api-Key: $ADMIN_API_KEY" +``` + +## Project Structure + +``` +cashumints-api/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ index.js # Server entry point +β”‚ β”œβ”€β”€ config.js # Configuration +β”‚ β”œβ”€β”€ db/ +β”‚ β”‚ β”œβ”€β”€ connection.js # Database connection +β”‚ β”‚ β”œβ”€β”€ migrate.js # Migration runner +β”‚ β”‚ β”œβ”€β”€ schema.sql # Main schema +β”‚ β”‚ └── schema-admin.sql # Admin schema +β”‚ β”œβ”€β”€ docs/ +β”‚ β”‚ └── openapi.js # OpenAPI specification +β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”œβ”€β”€ adminAuth.js # Admin authentication +β”‚ β”‚ β”œβ”€β”€ errorHandler.js # Error handling +β”‚ β”‚ └── rateLimit.js # Rate limiting +β”‚ β”œβ”€β”€ routes/ +β”‚ β”‚ β”œβ”€β”€ admin.js # Admin endpoints +β”‚ β”‚ β”œβ”€β”€ mints.js # Mint endpoints +β”‚ β”‚ └── system.js # System endpoints +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ AdminService.js # Admin operations +β”‚ β”‚ β”œβ”€β”€ MintService.js # Mint CRUD +β”‚ β”‚ β”œβ”€β”€ ProbeService.js # HTTP probing +β”‚ β”‚ β”œβ”€β”€ MetadataService.js # NUT-06 metadata +β”‚ β”‚ β”œβ”€β”€ UptimeService.js # Uptime rollups +β”‚ β”‚ β”œβ”€β”€ TrustService.js # Trust scoring +β”‚ β”‚ β”œβ”€β”€ ReviewService.js # Nostr reviews +β”‚ β”‚ β”œβ”€β”€ PageviewService.js # Analytics +β”‚ β”‚ └── JobService.js # Background jobs +β”‚ β”œβ”€β”€ utils/ +β”‚ β”‚ β”œβ”€β”€ url.js # URL normalization +β”‚ β”‚ β”œβ”€β”€ crypto.js # Hashing +β”‚ β”‚ └── time.js # Time utilities +β”‚ └── workers/ +β”‚ β”œβ”€β”€ index.js # Combined runner +β”‚ β”œβ”€β”€ probe.js # Mint probing +β”‚ β”œβ”€β”€ metadata.js # Metadata fetching +β”‚ β”œβ”€β”€ nostr.js # Nostr ingestion +β”‚ β”œβ”€β”€ rollup.js # Uptime aggregation +β”‚ └── trust.js # Trust calculation +β”œβ”€β”€ deploy/ +β”‚ β”œβ”€β”€ cashumints-api.service # API systemd service +β”‚ β”œβ”€β”€ cashumints-workers.service # Workers systemd service +β”‚ └── nginx.conf # Nginx configuration +β”œβ”€β”€ data/ # SQLite database (auto-created) +β”œβ”€β”€ env.example # Configuration template +β”œβ”€β”€ package.json # Dependencies +└── README.md # This file +``` + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode (auto-reload) +npm run dev + +# Run specific worker +npm run worker:probe +npm run worker:metadata +npm run worker:nostr +npm run worker:rollup +npm run worker:trust +``` + +## Design Principles + +1. **History is never erased** - All raw data is stored and aggregated. Nothing is overwritten. +2. **Offline β‰  dead** - Mints transition through states. Recovery is always possible. +3. **URL β‰  identity** - Mint identity is stable. Multiple URLs can point to the same mint. +4. **Trust is derived** - Every trust signal is computed from stored data with full breakdown. +5. **Self-hostable** - SQLite by default. No Redis or external queues required. +6. **Admin transparency** - Every admin action is audited. No hidden mutations. + +## License + +MIT + +## Links + +- [Cashu Protocol](https://cashu.space) +- [Cashu Documentation](https://docs.cashu.space) +- [NIP-87 Specification](https://github.com/nostr-protocol/nips/blob/master/87.md) +- [Cashu-TS Library](https://cashu-ts.dev) diff --git a/deploy/cashumints-api.service b/deploy/cashumints-api.service new file mode 100644 index 0000000..491d249 --- /dev/null +++ b/deploy/cashumints-api.service @@ -0,0 +1,64 @@ +# Cashumints.space API - Systemd Service File +# +# Installation: +# sudo cp deploy/cashumints-api.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable cashumints-api +# sudo systemctl start cashumints-api +# +# Management: +# sudo systemctl status cashumints-api +# sudo systemctl restart cashumints-api +# sudo journalctl -u cashumints-api -f + +[Unit] +Description=Cashumints.space API Server +Documentation=https://cashumints.space/docs +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=cashumints +Group=cashumints +WorkingDirectory=/opt/cashumints-api + +# Environment +Environment=NODE_ENV=production +Environment=PORT=3000 +EnvironmentFile=/opt/cashumints-api/.env + +# Process +ExecStart=/usr/bin/node src/index.js +ExecReload=/bin/kill -HUP $MAINPID + +# Restart policy +Restart=always +RestartSec=5 +StartLimitInterval=60 +StartLimitBurst=3 + +# Resource limits +LimitNOFILE=65536 +LimitNPROC=4096 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/cashumints-api/data +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cashumints-api + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/cashumints-workers.service b/deploy/cashumints-workers.service new file mode 100644 index 0000000..b90a306 --- /dev/null +++ b/deploy/cashumints-workers.service @@ -0,0 +1,63 @@ +# Cashumints.space Workers - Systemd Service File +# +# Installation: +# sudo cp deploy/cashumints-workers.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable cashumints-workers +# sudo systemctl start cashumints-workers +# +# Management: +# sudo systemctl status cashumints-workers +# sudo systemctl restart cashumints-workers +# sudo journalctl -u cashumints-workers -f + +[Unit] +Description=Cashumints.space Background Workers +Documentation=https://cashumints.space/docs +After=network.target cashumints-api.service +Wants=network-online.target + +[Service] +Type=simple +User=cashumints +Group=cashumints +WorkingDirectory=/opt/cashumints-api + +# Environment +Environment=NODE_ENV=production +EnvironmentFile=/opt/cashumints-api/.env + +# Process +ExecStart=/usr/bin/node src/workers/index.js +ExecReload=/bin/kill -HUP $MAINPID + +# Restart policy +Restart=always +RestartSec=10 +StartLimitInterval=120 +StartLimitBurst=5 + +# Resource limits +LimitNOFILE=65536 +LimitNPROC=4096 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/cashumints-api/data +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cashumints-workers + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..807b5f7 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# ============================================ +# Cashumints.space API - Installation Script +# ============================================ +# +# Usage: +# chmod +x deploy/install.sh +# sudo ./deploy/install.sh +# +# This script: +# 1. Creates cashumints user +# 2. Installs the application to /opt/cashumints-api +# 3. Sets up systemd services +# 4. Configures basic Nginx +# +# ============================================ + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +APP_USER="cashumints" +APP_DIR="/opt/cashumints-api" +CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}β•‘ Cashumints.space API - Installation Script β•‘${NC}" +echo -e "${GREEN}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Error: Please run as root (sudo)${NC}" + exit 1 +fi + +# Check Node.js +if ! command -v node &> /dev/null; then + echo -e "${RED}Error: Node.js is not installed${NC}" + echo "Install with: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install -y nodejs" + exit 1 +fi + +NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) +if [ "$NODE_VERSION" -lt 18 ]; then + echo -e "${RED}Error: Node.js 18+ required (found v${NODE_VERSION})${NC}" + exit 1 +fi + +echo -e "${GREEN}βœ“${NC} Node.js $(node -v) detected" + +# Create user +echo -e "${YELLOW}Creating application user...${NC}" +if id "$APP_USER" &>/dev/null; then + echo -e "${GREEN}βœ“${NC} User $APP_USER already exists" +else + useradd -r -s /bin/false $APP_USER + echo -e "${GREEN}βœ“${NC} Created user $APP_USER" +fi + +# Create application directory +echo -e "${YELLOW}Setting up application directory...${NC}" +if [ -d "$APP_DIR" ]; then + echo -e "${YELLOW}⚠${NC} Directory $APP_DIR exists, updating..." +else + mkdir -p $APP_DIR +fi + +# Copy application files +cp -r "$CURRENT_DIR"/* $APP_DIR/ +rm -rf $APP_DIR/node_modules 2>/dev/null || true + +# Create data directory +mkdir -p $APP_DIR/data +chown -R $APP_USER:$APP_USER $APP_DIR/data + +echo -e "${GREEN}βœ“${NC} Application files copied to $APP_DIR" + +# Install dependencies +echo -e "${YELLOW}Installing dependencies...${NC}" +cd $APP_DIR +npm install --production --silent +echo -e "${GREEN}βœ“${NC} Dependencies installed" + +# Setup configuration +if [ ! -f "$APP_DIR/.env" ]; then + echo -e "${YELLOW}Creating configuration...${NC}" + cp $APP_DIR/env.example $APP_DIR/.env + + # Generate admin key + ADMIN_KEY=$(openssl rand -hex 32) + sed -i "s/^ADMIN_API_KEY=$/ADMIN_API_KEY=$ADMIN_KEY/" $APP_DIR/.env + + echo -e "${GREEN}βœ“${NC} Configuration created" + echo -e "${YELLOW}⚠ IMPORTANT: Your admin API key is:${NC}" + echo -e "${GREEN}$ADMIN_KEY${NC}" + echo "" + echo "Save this key! You'll need it for admin operations." + echo "" +else + echo -e "${GREEN}βœ“${NC} Configuration already exists" +fi + +# Set permissions +chown -R root:$APP_USER $APP_DIR +chmod -R 750 $APP_DIR +chmod 640 $APP_DIR/.env + +# Initialize database +echo -e "${YELLOW}Initializing database...${NC}" +sudo -u $APP_USER node $APP_DIR/src/db/migrate.js +echo -e "${GREEN}βœ“${NC} Database initialized" + +# Install systemd services +echo -e "${YELLOW}Installing systemd services...${NC}" +cp $APP_DIR/deploy/cashumints-api.service /etc/systemd/system/ +cp $APP_DIR/deploy/cashumints-workers.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable cashumints-api cashumints-workers +echo -e "${GREEN}βœ“${NC} Systemd services installed and enabled" + +# Install nginx config if nginx is available +if command -v nginx &> /dev/null; then + echo -e "${YELLOW}Installing Nginx configuration...${NC}" + cp $APP_DIR/deploy/nginx.conf /etc/nginx/sites-available/cashumints-api + + if [ ! -L /etc/nginx/sites-enabled/cashumints-api ]; then + ln -s /etc/nginx/sites-available/cashumints-api /etc/nginx/sites-enabled/ + fi + + echo -e "${GREEN}βœ“${NC} Nginx configuration installed" + echo -e "${YELLOW}⚠ Note: Update server_name and SSL paths in /etc/nginx/sites-available/cashumints-api${NC}" +else + echo -e "${YELLOW}⚠${NC} Nginx not found, skipping Nginx setup" +fi + +# Summary +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}β•‘ Installation Complete! β•‘${NC}" +echo -e "${GREEN}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" +echo "" +echo "Next steps:" +echo "" +echo "1. Review configuration:" +echo " sudo nano $APP_DIR/.env" +echo "" +echo "2. Start services:" +echo " sudo systemctl start cashumints-api" +echo " sudo systemctl start cashumints-workers" +echo "" +echo "3. Check status:" +echo " sudo systemctl status cashumints-api" +echo " sudo systemctl status cashumints-workers" +echo "" +echo "4. View logs:" +echo " sudo journalctl -u cashumints-api -f" +echo " sudo journalctl -u cashumints-workers -f" +echo "" +echo "5. Test API:" +echo " curl http://localhost:3000/v1/health" +echo "" +if command -v nginx &> /dev/null; then + echo "6. Setup SSL (Let's Encrypt):" + echo " sudo certbot --nginx -d api.cashumints.space" + echo " sudo systemctl reload nginx" + echo "" +fi +echo -e "${GREEN}Documentation: http://localhost:3000/docs${NC}" +echo "" + diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..19cb01f --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,160 @@ +# Cashumints.space API - Nginx Configuration +# +# Installation: +# sudo cp deploy/nginx.conf /etc/nginx/sites-available/cashumints-api +# sudo ln -s /etc/nginx/sites-available/cashumints-api /etc/nginx/sites-enabled/ +# sudo nginx -t +# sudo systemctl reload nginx +# +# SSL Certificate (Let's Encrypt): +# sudo certbot --nginx -d api.cashumints.space + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=5r/s; +limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + +# Upstream API server +upstream cashumints_api { + server 127.0.0.1:3000; + keepalive 32; +} + +# Redirect HTTP to HTTPS +server { + listen 80; + listen [::]:80; + server_name api.cashumints.space; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# Main HTTPS server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.cashumints.space; + + # SSL Configuration + ssl_certificate /etc/letsencrypt/live/api.cashumints.space/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.cashumints.space/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # Modern SSL configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # HSTS + add_header Strict-Transport-Security "max-age=63072000" always; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Logging + access_log /var/log/nginx/cashumints-api.access.log; + error_log /var/log/nginx/cashumints-api.error.log; + + # Connection limits + limit_conn conn_limit 20; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types application/json application/javascript text/plain text/css text/xml; + + # Client settings + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + + # Proxy settings + proxy_http_version 1.1; + 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_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # Health check (no rate limit) + location = /v1/health { + proxy_pass http://cashumints_api; + proxy_cache_bypass 1; + } + + # Swagger documentation (relaxed rate limit) + location /docs { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass http://cashumints_api; + } + + location = /openapi.json { + limit_req zone=api_limit burst=10 nodelay; + proxy_pass http://cashumints_api; + # Cache OpenAPI spec + proxy_cache_valid 200 5m; + } + + # Admin endpoints (strict rate limit) + location /v1/admin { + limit_req zone=admin_limit burst=5 nodelay; + proxy_pass http://cashumints_api; + + # Only allow from specific IPs (optional) + # allow 10.0.0.0/8; + # allow 192.168.0.0/16; + # deny all; + } + + # API endpoints + location /v1 { + limit_req zone=api_limit burst=50 nodelay; + proxy_pass http://cashumints_api; + + # Cache GET requests for 10 seconds + proxy_cache_valid 200 10s; + } + + # Root and favicon + location = / { + proxy_pass http://cashumints_api; + } + + location = /favicon.ico { + return 204; + access_log off; + } + + # Block common attack vectors + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~* \.(git|env|sql|bak|old|tmp)$ { + deny all; + access_log off; + log_not_found off; + } +} + diff --git a/env.example b/env.example new file mode 100644 index 0000000..096707e --- /dev/null +++ b/env.example @@ -0,0 +1,139 @@ +# ============================================ +# Cashumints.space API Configuration +# ============================================ +# Copy this file to .env and adjust values +# +# Production setup: +# cp env.example .env +# openssl rand -hex 32 # Generate ADMIN_API_KEY +# nano .env # Edit configuration +# +# ============================================ + +# ============================================ +# Server Configuration +# ============================================ +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=production + +# ============================================ +# Admin Configuration +# ============================================ +# API key for admin endpoints (REQUIRED in production) +# Generate a secure key: openssl rand -hex 32 +ADMIN_API_KEY= + +# ============================================ +# Database Configuration +# ============================================ +# SQLite database path (relative or absolute) +# Ensure the directory exists and is writable +DATABASE_PATH=./data/cashumints.db + +# ============================================ +# Probing Configuration +# ============================================ +# Timeout for probe requests (milliseconds) +PROBE_TIMEOUT_MS=10000 + +# Probe interval for online mints (milliseconds) +# Default: 300000 (5 minutes) +PROBE_INTERVAL_ONLINE_MS=300000 + +# Probe interval for offline mints (milliseconds) +# Default: 900000 (15 minutes) +PROBE_INTERVAL_OFFLINE_MS=900000 + +# Number of consecutive failures before marking offline +CONSECUTIVE_FAILURES_OFFLINE=3 + +# Days offline before marking as abandoned +ABANDONED_AFTER_DAYS=30 + +# RTT threshold for degraded status (milliseconds) +DEGRADED_RTT_MS=2000 + +# ============================================ +# Metadata Configuration (NUT-06) +# ============================================ +# Minimum interval between metadata fetches (milliseconds) +# Default: 3600000 (1 hour) +METADATA_FETCH_INTERVAL_MS=3600000 + +# ============================================ +# Nostr Configuration (NIP-87) +# ============================================ +# Comma-separated list of Nostr relay URLs +# Reviews and mint announcements are ingested from these relays +NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://relay.cashumints.space,wss://relay.primal.net,wss://purplepag.es + +# ============================================ +# Trust Score Configuration +# ============================================ +# Component weights (should sum to ~100 before penalties) +TRUST_WEIGHT_UPTIME=40 +TRUST_WEIGHT_SPEED=25 +TRUST_WEIGHT_REVIEWS=20 +TRUST_WEIGHT_IDENTITY=10 + +# Maximum penalty points for issues +TRUST_PENALTY_MAX=15 + +# ============================================ +# Rate Limiting +# ============================================ +# Time window for rate limiting (milliseconds) +# Default: 60000 (1 minute) +RATE_LIMIT_WINDOW_MS=60000 + +# Maximum requests per window per IP +RATE_LIMIT_MAX_REQUESTS=100 + +# ============================================ +# CORS Configuration +# ============================================ +# Allowed origins (comma-separated, use * for all) +CORS_ORIGINS=* + +# ============================================ +# Logging +# ============================================ +# Log level: debug, info, warn, error +LOG_LEVEL=info + +# Enable request logging (true/false) +REQUEST_LOGGING=true + +# ============================================ +# Plausible Analytics +# ============================================ +# Self-hosted Plausible instance URL +PLAUSIBLE_URL=https://analytics.azzamo.net + +# Plausible API key for stats access +# Generate from your Plausible dashboard: Settings > API Keys +PLAUSIBLE_API_KEY= + +# Site ID/domain configured in Plausible +PLAUSIBLE_SITE_ID=cashumints.space + +# ============================================ +# Quick Reference +# ============================================ +# +# Start API server: +# npm start +# +# Start workers: +# npm run workers +# +# Test health: +# curl http://localhost:3000/v1/health +# +# Test admin (requires ADMIN_API_KEY): +# curl -H "X-Admin-Api-Key: YOUR_KEY" http://localhost:3000/v1/admin/system/metrics +# +# View docs: +# open http://localhost:3000/docs +# diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c8f0a0d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1675 @@ +{ + "name": "cashumints-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cashumints-api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "^2.2.0", + "better-sqlite3": "^11.6.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.21.2", + "helmet": "^8.0.0", + "nostr-tools": "^2.10.4", + "swagger-ui-express": "^5.0.1", + "uuid": "^11.0.3", + "ws": "^8.18.0" + }, + "devDependencies": { + "dotenv": "^16.4.7" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cashu/cashu-ts": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz", + "integrity": "sha512-4HO3LC3VqiMs0K7ccQdfSs3l1wJNL0VuE8ZQ6zAfMsoeKRwswA1eC5BaGFrEDv7PcPqjliE/RBRw3+1Hz/SmsA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.9.5", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0" + }, + "engines": { + "node": ">=22.4.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nostr-tools": { + "version": "2.19.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz", + "integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/nostr-tools/node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..43da156 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "cashumints-api", + "version": "1.0.0", + "description": "Cashumints.space API - Observability, discovery, and reputation layer for Cashu mints", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "migrate": "node src/db/migrate.js", + "worker:probe": "node src/workers/probe.js", + "worker:metadata": "node src/workers/metadata.js", + "worker:nostr": "node src/workers/nostr.js", + "worker:rollup": "node src/workers/rollup.js", + "worker:trust": "node src/workers/trust.js", + "workers": "node src/workers/index.js", + "prod": "NODE_ENV=production node src/index.js", + "prod:workers": "NODE_ENV=production node src/workers/index.js" + }, + "keywords": [ + "cashu", + "ecash", + "bitcoin", + "nostr", + "nip-87", + "mint", + "monitoring", + "uptime", + "trust-score" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "^2.2.0", + "better-sqlite3": "^11.6.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "express": "^4.21.2", + "helmet": "^8.0.0", + "nostr-tools": "^2.10.4", + "swagger-ui-express": "^5.0.1", + "uuid": "^11.0.3", + "ws": "^8.18.0" + }, + "devDependencies": { + "dotenv": "^16.4.7" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..f8be36e --- /dev/null +++ b/src/config.js @@ -0,0 +1,91 @@ +/** + * Cashumints.space API Configuration + * + * All configuration values with sensible defaults. + * Override via environment variables. + */ + +// Load dotenv first, before reading any env vars +import dotenv from 'dotenv'; +dotenv.config(); + +export const config = { + // Server + port: parseInt(process.env.PORT || '3000', 10), + host: process.env.HOST || '127.0.0.1', + nodeEnv: process.env.NODE_ENV || 'development', + + // Admin + adminApiKey: process.env.ADMIN_API_KEY || null, + + // Database + databasePath: process.env.DATABASE_PATH || './data/cashumints.db', + + // Probing + probeTimeoutMs: parseInt(process.env.PROBE_TIMEOUT_MS || '10000', 10), + probeIntervalOnlineMs: parseInt(process.env.PROBE_INTERVAL_ONLINE_MS || '300000', 10), // 5 min + probeIntervalOfflineMs: parseInt(process.env.PROBE_INTERVAL_OFFLINE_MS || '900000', 10), // 15 min + consecutiveFailuresOffline: parseInt(process.env.CONSECUTIVE_FAILURES_OFFLINE || '3', 10), + abandonedAfterDays: parseInt(process.env.ABANDONED_AFTER_DAYS || '30', 10), + + // Metadata + metadataFetchIntervalMs: parseInt(process.env.METADATA_FETCH_INTERVAL_MS || '3600000', 10), // 1 hour + + // Nostr + nostrRelays: (process.env.NOSTR_RELAYS || 'wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol,wss://relay.primal.net').split(',').map(r => r.trim()).filter(Boolean), + + // Trust Score Weights (out of 100 total) + trustWeights: { + uptime: parseInt(process.env.TRUST_WEIGHT_UPTIME || '40', 10), + speed: parseInt(process.env.TRUST_WEIGHT_SPEED || '25', 10), + reviews: parseInt(process.env.TRUST_WEIGHT_REVIEWS || '20', 10), + identity: parseInt(process.env.TRUST_WEIGHT_IDENTITY || '10', 10), + penaltyMax: parseInt(process.env.TRUST_PENALTY_MAX || '15', 10), + }, + + // Rate Limiting + rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10), + rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), + + // Cashu + mintInfoEndpoint: '/v1/info', + mintKeysEndpoint: '/v1/keys', + + // NIP-87 Event Kinds + nip87: { + cashuMintAnnouncement: 38172, // Cashu mint self-announcement + fedimintAnnouncement: 38173, // Fedimint announcement + recommendation: 38000, // User recommendation/review (uses #k tag) + }, + + // Status thresholds + degradedRttMs: parseInt(process.env.DEGRADED_RTT_MS || '2000', 10), + + // Logging + logLevel: process.env.LOG_LEVEL || 'info', + requestLogging: process.env.REQUEST_LOGGING === 'true' || process.env.NODE_ENV !== 'production', + + // Plausible Analytics + plausible: { + url: process.env.PLAUSIBLE_URL || 'https://analytics.azzamo.net', + apiKey: process.env.PLAUSIBLE_API_KEY || null, + siteId: process.env.PLAUSIBLE_SITE_ID || 'cashumints.space', + }, +}; + +// Validate configuration +if (config.port < 1 || config.port > 65535) { + throw new Error(`Invalid PORT: ${config.port}`); +} + +if (config.nostrRelays.length === 0) { + console.warn('[Config] No Nostr relays configured, review ingestion will be disabled'); +} + +if (!config.adminApiKey) { + console.warn('[Config] ADMIN_API_KEY not set - admin endpoints will be disabled'); +} + +if (!config.plausible.apiKey) { + console.warn('[Config] PLAUSIBLE_API_KEY not set - will use local pageview tracking only'); +} \ No newline at end of file diff --git a/src/db/connection.js b/src/db/connection.js new file mode 100644 index 0000000..47c810a --- /dev/null +++ b/src/db/connection.js @@ -0,0 +1,154 @@ +/** + * Database Connection Manager + * + * SQLite connection with better-sqlite3 for synchronous operations. + * Handles connection pooling and migrations. + */ + +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { config } from '../config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let db = null; + +/** + * Initialize database connection + */ +export function initDatabase() { + if (db) return db; + + // Ensure data directory exists + const dbDir = dirname(config.databasePath); + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + // Create database connection + db = new Database(config.databasePath); + + // Enable WAL mode for better concurrent access + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('synchronous = NORMAL'); + db.pragma('cache_size = 10000'); + db.pragma('temp_store = MEMORY'); + + // Run migrations + runMigrations(db); + + console.log(`[DB] Connected to ${config.databasePath}`); + + return db; +} + +/** + * Get database instance + */ +export function getDb() { + if (!db) { + return initDatabase(); + } + return db; +} + +/** + * Close database connection + */ +export function closeDatabase() { + if (db) { + db.close(); + db = null; + console.log('[DB] Connection closed'); + } +} + +/** + * Run database migrations + */ +function runMigrations(database) { + // Run main schema + const schemaPath = join(__dirname, 'schema.sql'); + const schema = readFileSync(schemaPath, 'utf-8'); + + try { + database.exec(schema); + console.log('[DB] Main schema applied'); + } catch (err) { + if (!err.message.includes('already exists')) { + console.error(`[DB] Schema error: ${err.message}`); + } + } + + // Run admin schema if exists + const adminSchemaPath = join(__dirname, 'schema-admin.sql'); + if (existsSync(adminSchemaPath)) { + const adminSchema = readFileSync(adminSchemaPath, 'utf-8'); + try { + database.exec(adminSchema); + console.log('[DB] Admin schema applied'); + } catch (err) { + if (!err.message.includes('already exists')) { + console.error(`[DB] Admin schema error: ${err.message}`); + } + } + } + + // Add visibility column to mints if not exists (SQLite migration) + try { + database.exec(`ALTER TABLE mints ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'hidden', 'blocked'))`); + console.log('[DB] Added visibility column to mints'); + } catch (err) { + // Column already exists + if (!err.message.includes('duplicate column')) { + // Only log if it's not a duplicate column error + } + } + + console.log('[DB] Migrations completed'); +} + +/** + * Transaction helper + */ +export function transaction(fn) { + const database = getDb(); + return database.transaction(fn)(); +} + +/** + * Prepare statement with caching + */ +const statementCache = new Map(); + +export function prepare(sql) { + const database = getDb(); + if (!statementCache.has(sql)) { + statementCache.set(sql, database.prepare(sql)); + } + return statementCache.get(sql); +} + +/** + * Query helpers + */ +export function query(sql, params = []) { + return prepare(sql).all(...params); +} + +export function queryOne(sql, params = []) { + return prepare(sql).get(...params); +} + +export function run(sql, params = []) { + return prepare(sql).run(...params); +} + +export function exec(sql) { + return getDb().exec(sql); +} + diff --git a/src/db/migrate.js b/src/db/migrate.js new file mode 100644 index 0000000..67f4efd --- /dev/null +++ b/src/db/migrate.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Database Migration Runner + * + * Run with: node src/db/migrate.js + */ + +import { initDatabase, closeDatabase } from './connection.js'; + +console.log('[Migrate] Starting database migration...'); + +try { + initDatabase(); + console.log('[Migrate] Migration completed successfully'); +} catch (error) { + console.error('[Migrate] Migration failed:', error.message); + process.exit(1); +} finally { + closeDatabase(); +} + diff --git a/src/db/schema-admin.sql b/src/db/schema-admin.sql new file mode 100644 index 0000000..dabe753 --- /dev/null +++ b/src/db/schema-admin.sql @@ -0,0 +1,59 @@ +-- Cashumints.space Admin Schema Extension +-- Additional tables for admin operations + +-- ============================================ +-- AUDIT LOG +-- ============================================ + +CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + before_state TEXT, -- JSON + after_state TEXT, -- JSON + notes TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON admin_audit_log(admin_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_action ON admin_audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_log_target ON admin_audit_log(target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON admin_audit_log(created_at); + +-- ============================================ +-- MINT MERGES +-- ============================================ + +CREATE TABLE IF NOT EXISTS mint_merges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + merge_id TEXT NOT NULL UNIQUE, + source_mint_id TEXT NOT NULL, + target_mint_id TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'reverted')), + merged_at TEXT NOT NULL DEFAULT (datetime('now')), + reverted_at TEXT, + admin_id TEXT NOT NULL, + -- Store affected data for potential reversal + affected_urls TEXT, -- JSON array of URL IDs + affected_probes TEXT, -- JSON range of probe IDs + affected_reviews TEXT, -- JSON array of review IDs + affected_metadata TEXT -- JSON array of metadata history IDs +); + +CREATE INDEX IF NOT EXISTS idx_merges_source ON mint_merges(source_mint_id); +CREATE INDEX IF NOT EXISTS idx_merges_target ON mint_merges(target_mint_id); +CREATE INDEX IF NOT EXISTS idx_merges_status ON mint_merges(status); + +-- ============================================ +-- MINT VISIBILITY & ADMIN FLAGS +-- ============================================ + +-- Add visibility column to mints if not exists +-- Note: SQLite doesn't support IF NOT EXISTS for ALTER TABLE +-- This will be handled in migration code + diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..6d7f103 --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,271 @@ +-- Cashumints.space Database Schema +-- SQLite optimized for append-only historical data + +-- Enable foreign keys +PRAGMA foreign_keys = ON; + +-- ============================================ +-- CORE TABLES +-- ============================================ + +-- Mints: Core identity table +CREATE TABLE IF NOT EXISTS mints ( + mint_id TEXT PRIMARY KEY, + canonical_url TEXT NOT NULL, + name TEXT, + icon_url TEXT, + status TEXT NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown', 'online', 'degraded', 'offline', 'abandoned')), + offline_since TEXT, + last_success_at TEXT, + last_failure_at TEXT, + consecutive_failures INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_mints_status ON mints(status); +CREATE INDEX IF NOT EXISTS idx_mints_canonical_url ON mints(canonical_url); + +-- Mint URLs: Multiple URLs per mint +CREATE TABLE IF NOT EXISTS mint_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + url TEXT NOT NULL UNIQUE, + url_normalized TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'clearnet' CHECK (type IN ('clearnet', 'tor', 'mirror')), + active INTEGER NOT NULL DEFAULT 1, + discovered_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_mint_urls_mint_id ON mint_urls(mint_id); +CREATE INDEX IF NOT EXISTS idx_mint_urls_url_normalized ON mint_urls(url_normalized); +CREATE INDEX IF NOT EXISTS idx_mint_urls_active ON mint_urls(active); + +-- ============================================ +-- PROBING & UPTIME +-- ============================================ + +-- Probes: Raw probe results (append-only) +CREATE TABLE IF NOT EXISTS probes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + url TEXT NOT NULL, + probed_at TEXT NOT NULL DEFAULT (datetime('now')), + success INTEGER NOT NULL, + status_code INTEGER, + rtt_ms INTEGER, + error_type TEXT, + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_probes_mint_id ON probes(mint_id); +CREATE INDEX IF NOT EXISTS idx_probes_probed_at ON probes(probed_at); +CREATE INDEX IF NOT EXISTS idx_probes_mint_probed ON probes(mint_id, probed_at); + +-- Uptime Rollups: Aggregated uptime data +CREATE TABLE IF NOT EXISTS uptime_rollups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + window TEXT NOT NULL CHECK (window IN ('1h', '24h', '7d', '30d')), + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + uptime_pct REAL NOT NULL, + downtime_seconds INTEGER NOT NULL DEFAULT 0, + avg_rtt_ms REAL, + p95_rtt_ms REAL, + total_checks INTEGER NOT NULL DEFAULT 0, + ok_checks INTEGER NOT NULL DEFAULT 0, + incident_count INTEGER NOT NULL DEFAULT 0, + computed_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(mint_id, window, period_start) +); + +CREATE INDEX IF NOT EXISTS idx_uptime_rollups_mint_window ON uptime_rollups(mint_id, window); + +-- Incidents: Downtime events +CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + started_at TEXT NOT NULL, + resolved_at TEXT, + duration_seconds INTEGER, + severity TEXT NOT NULL DEFAULT 'minor' CHECK (severity IN ('minor', 'major', 'critical')) +); + +CREATE INDEX IF NOT EXISTS idx_incidents_mint_id ON incidents(mint_id); +CREATE INDEX IF NOT EXISTS idx_incidents_started_at ON incidents(started_at); + +-- ============================================ +-- METADATA (NUT-06) +-- ============================================ + +-- Metadata Snapshots: Current state +CREATE TABLE IF NOT EXISTS metadata_snapshots ( + mint_id TEXT PRIMARY KEY REFERENCES mints(mint_id) ON DELETE CASCADE, + name TEXT, + pubkey TEXT, + version TEXT, + description TEXT, + description_long TEXT, + contact TEXT, + motd TEXT, + icon_url TEXT, + urls TEXT, -- JSON array + tos_url TEXT, + nuts TEXT, -- JSON object + server_time TEXT, + raw_json TEXT, -- Full raw response + content_hash TEXT, + last_fetched_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Metadata History: All changes (append-only) +CREATE TABLE IF NOT EXISTS metadata_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + change_type TEXT NOT NULL CHECK (change_type IN ('initial', 'update', 'error')), + diff TEXT, -- JSON diff of changes + content_hash TEXT, + version TEXT, + raw_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_metadata_history_mint_id ON metadata_history(mint_id); +CREATE INDEX IF NOT EXISTS idx_metadata_history_fetched_at ON metadata_history(fetched_at); + +-- ============================================ +-- NOSTR REVIEWS (NIP-87) +-- ============================================ + +-- Nostr Events: Raw events (append-only) +CREATE TABLE IF NOT EXISTS nostr_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + kind INTEGER NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + content TEXT, + tags TEXT, -- JSON array + sig TEXT NOT NULL, + raw_json TEXT NOT NULL, + ingested_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_nostr_events_event_id ON nostr_events(event_id); +CREATE INDEX IF NOT EXISTS idx_nostr_events_kind ON nostr_events(kind); +CREATE INDEX IF NOT EXISTS idx_nostr_events_pubkey ON nostr_events(pubkey); +CREATE INDEX IF NOT EXISTS idx_nostr_events_created_at ON nostr_events(created_at); + +-- Reviews: Parsed review data linked to mints +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE REFERENCES nostr_events(event_id), + mint_id TEXT REFERENCES mints(mint_id), + mint_url TEXT, -- Original URL from event + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + content TEXT, + parsed_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_reviews_mint_id ON reviews(mint_id); +CREATE INDEX IF NOT EXISTS idx_reviews_pubkey ON reviews(pubkey); +CREATE INDEX IF NOT EXISTS idx_reviews_created_at ON reviews(created_at); + +-- ============================================ +-- TRUST SCORES +-- ============================================ + +CREATE TABLE IF NOT EXISTS trust_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + score_total INTEGER NOT NULL CHECK (score_total >= 0 AND score_total <= 100), + score_level TEXT NOT NULL CHECK (score_level IN ('unknown', 'low', 'medium', 'high', 'excellent')), + breakdown TEXT NOT NULL, -- JSON breakdown of score components + computed_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(mint_id, computed_at) +); + +CREATE INDEX IF NOT EXISTS idx_trust_scores_mint_id ON trust_scores(mint_id); + +-- Current trust score view +CREATE VIEW IF NOT EXISTS current_trust_scores AS +SELECT + t1.* +FROM trust_scores t1 +INNER JOIN ( + SELECT mint_id, MAX(computed_at) as max_computed_at + FROM trust_scores + GROUP BY mint_id +) t2 ON t1.mint_id = t2.mint_id AND t1.computed_at = t2.max_computed_at; + +-- ============================================ +-- PAGEVIEWS & ANALYTICS +-- ============================================ + +CREATE TABLE IF NOT EXISTS pageviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + viewed_at TEXT NOT NULL DEFAULT (datetime('now')), + user_agent TEXT, + referer TEXT +); + +CREATE INDEX IF NOT EXISTS idx_pageviews_mint_id ON pageviews(mint_id); +CREATE INDEX IF NOT EXISTS idx_pageviews_viewed_at ON pageviews(viewed_at); +CREATE INDEX IF NOT EXISTS idx_pageviews_session_id ON pageviews(session_id); + +-- Pageview rollups +CREATE TABLE IF NOT EXISTS pageview_rollups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mint_id TEXT NOT NULL REFERENCES mints(mint_id) ON DELETE CASCADE, + window TEXT NOT NULL CHECK (window IN ('24h', '7d', '30d')), + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + view_count INTEGER NOT NULL DEFAULT 0, + unique_sessions INTEGER NOT NULL DEFAULT 0, + computed_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(mint_id, window, period_start) +); + +CREATE INDEX IF NOT EXISTS idx_pageview_rollups_mint_window ON pageview_rollups(mint_id, window); + +-- ============================================ +-- JOB QUEUE +-- ============================================ + +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK (type IN ('probe', 'metadata', 'rollup', 'trust_score', 'nostr_sync', 'cleanup')), + payload TEXT, -- JSON + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), + priority INTEGER NOT NULL DEFAULT 0, + run_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + retries INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at); +CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs(type); + +-- ============================================ +-- SYSTEM STATS +-- ============================================ + +CREATE TABLE IF NOT EXISTS system_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stat_name TEXT NOT NULL, + stat_value TEXT NOT NULL, + recorded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_system_stats_name ON system_stats(stat_name); + diff --git a/src/docs/openapi.js b/src/docs/openapi.js new file mode 100644 index 0000000..7daaf5b --- /dev/null +++ b/src/docs/openapi.js @@ -0,0 +1,2300 @@ +/** + * OpenAPI 3.0 Specification + * + * Full API documentation for Cashumints.space + */ + +export const openApiSpec = { + openapi: '3.0.3', + info: { + title: 'Cashumints.space API', + description: ` +# Cashumints.space API + +A decentralized observability, discovery, and reputation API for the Cashu mint ecosystem. + +## Overview + +This API provides: +- **Mint Discovery** - Track Cashu mints across the ecosystem +- **Uptime Monitoring** - Continuous probing with historical data +- **Metadata Tracking** - NUT-06 compliant metadata with change history +- **Nostr Reviews** - NIP-87 based review ingestion from relays +- **Trust Scores** - Transparent, explainable reputation scoring +- **Analytics** - Pageviews, trending, and popularity metrics + +## Key Concepts + +### Mint Identity +Each mint is identified by a stable \`mint_id\` (UUID). A mint can have multiple URLs (clearnet, Tor, mirrors). All URLs resolve to the same mint identity. + +### Mint Status +Mints cycle through states: \`unknown\` β†’ \`online\` β†’ \`degraded\` β†’ \`offline\` β†’ \`abandoned\` + +### Trust Scores +Scores range from 0-100 and are computed from: +- Uptime reliability (40 points max) +- Response speed (25 points max) +- Nostr reviews (20 points max) +- Identity completeness (10 points max) +- Penalties for instability (up to -15 points) + +## Authentication +This is a public, read-only API. No authentication required for read access. + +## Rate Limiting +100 requests per minute per IP. Rate limit headers are included in responses. + +## Admin API +Admin endpoints require the \`X-Admin-Api-Key\` header. All admin actions are audited. Admin operations never delete raw data - they annotate, correct routing, or trigger recomputation. + `, + version: '1.0.0', + contact: { + name: 'Cashumints.space', + url: 'https://cashumints.space' + }, + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT' + } + }, + externalDocs: { + description: 'Cashu Protocol Documentation', + url: 'https://docs.cashu.space' + }, + servers: [{ + url: '/v1', + description: 'Current server (relative)' + }, + { + url: 'https://api.cashumints.space/v1', + description: 'Production API' + }, + { + url: 'http://localhost:3000/v1', + description: 'Local development' + } + ], + tags: [{ + name: 'System', + description: 'Health checks and system statistics' + }, + { + name: 'Mints', + description: 'Mint discovery and listing' + }, + { + name: 'Mint Details', + description: 'Detailed mint information' + }, + { + name: 'Metadata', + description: 'NUT-06 metadata endpoints' + }, + { + name: 'Uptime', + description: 'Uptime and reliability metrics' + }, + { + name: 'Trust', + description: 'Trust scores and reputation' + }, + { + name: 'Reviews', + description: 'Nostr-based reviews (NIP-87)' + }, + { + name: 'Analytics', + description: 'Pageviews and popularity' + }, + { + name: 'Admin', + description: 'Admin-only endpoints (requires X-Admin-Api-Key header)' + } + ], + paths: { + '/health': { + get: { + tags: ['System'], + summary: 'Health check', + description: 'Returns the health status of the API', + operationId: 'getHealth', + responses: { + 200: { + description: 'API is healthy', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthResponse' } + } + } + }, + 503: { + description: 'API is unhealthy', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthResponse' } + } + } + } + } + } + }, + '/stats': { + get: { + tags: ['System'], + summary: 'System statistics', + description: 'Returns overall system statistics including mint counts, activity, and trending data', + operationId: 'getStats', + responses: { + 200: { + description: 'System statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/StatsResponse' } + } + } + } + } + } + }, + '/reviews': { + get: { + tags: ['Reviews'], + summary: 'Global reviews feed', + description: 'Returns all reviews across the ecosystem with optional filtering', + operationId: 'getGlobalReviews', + parameters: [{ + name: 'mint_id', + in: 'query', + description: 'Filter by mint ID', + schema: { type: 'string', format: 'uuid' } + }, + { + name: 'mint_url', + in: 'query', + description: 'Filter by mint URL', + schema: { type: 'string' } + }, + { + name: 'since', + in: 'query', + description: 'Unix timestamp - only reviews after this time', + schema: { type: 'integer' } + }, + { + name: 'until', + in: 'query', + description: 'Unix timestamp - only reviews before this time', + schema: { type: 'integer' } + }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of results', + schema: { type: 'integer', default: 50, maximum: 200 } + }, + { + name: 'offset', + in: 'query', + description: 'Number of results to skip', + schema: { type: 'integer', default: 0 } + } + ], + responses: { + 200: { + description: 'Review list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/GlobalReviewsResponse' } + } + } + } + } + } + }, + '/analytics/uptime': { + get: { + tags: ['Analytics'], + summary: 'Ecosystem uptime analytics', + description: 'Returns ecosystem-wide uptime and reliability metrics', + operationId: 'getUptimeAnalytics', + parameters: [{ + name: 'window', + in: 'query', + description: 'Time window for analytics', + schema: { + type: 'string', + enum: ['24h', '7d', '30d'], + default: '24h' + } + }], + responses: { + 200: { + description: 'Uptime analytics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UptimeAnalytics' } + } + } + } + } + } + }, + '/analytics/versions': { + get: { + tags: ['Analytics'], + summary: 'Version distribution analytics', + description: 'Returns mint version distribution across the ecosystem', + operationId: 'getVersionAnalytics', + responses: { + 200: { + description: 'Version analytics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/VersionAnalytics' } + } + } + } + } + } + }, + '/analytics/nuts': { + get: { + tags: ['Analytics'], + summary: 'NUT support analytics', + description: 'Returns NUT support distribution across all mints', + operationId: 'getNutsAnalytics', + responses: { + 200: { + description: 'NUT analytics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/NutsAnalytics' } + } + } + } + } + } + }, + '/mints/trending': { + get: { + tags: ['Mints'], + summary: 'Get trending mints', + description: 'Returns mints ranked by view velocity (popularity)', + operationId: 'getTrendingMints', + parameters: [{ + name: 'window', + in: 'query', + description: 'Time window for trending calculation', + schema: { + type: 'string', + enum: ['24h', '7d'], + default: '7d' + } + }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of results', + schema: { type: 'integer', default: 10, maximum: 50 } + } + ], + responses: { + 200: { + description: 'Trending mints', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TrendingMintsResponse' } + } + } + } + } + } + }, + '/mints': { + get: { + tags: ['Mints'], + summary: 'List all mints', + description: 'Returns a paginated list of all tracked mints with optional filtering', + operationId: 'listMints', + parameters: [{ + name: 'status', + in: 'query', + description: 'Filter by mint status', + schema: { + type: 'string', + enum: ['unknown', 'online', 'degraded', 'offline', 'abandoned'] + } + }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of results', + schema: { type: 'integer', default: 100, maximum: 500 } + }, + { + name: 'offset', + in: 'query', + description: 'Number of results to skip', + schema: { type: 'integer', default: 0 } + }, + { + name: 'sort_by', + in: 'query', + description: 'Sort field', + schema: { + type: 'string', + enum: ['created_at', 'updated_at', 'name', 'trust_score'], + default: 'created_at' + } + }, + { + name: 'sort_order', + in: 'query', + description: 'Sort order', + schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } + } + ], + responses: { + 200: { + description: 'List of mints', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintListResponse' } + } + } + } + } + } + }, + '/mints/submit': { + post: { + tags: ['Mints'], + summary: 'Submit a new mint', + description: 'Submit a mint URL for tracking. The mint will be probed and added to the index.', + operationId: 'submitMint', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintSubmission' } + } + } + }, + responses: { + 201: { + description: 'Mint submitted successfully', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintSubmissionResponse' } + } + } + }, + 400: { + description: 'Invalid URL or submission error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + } + } + } + }, + '/mints/{mint_id}': { + get: { + tags: ['Mint Details'], + summary: 'Get mint by ID', + description: 'Returns detailed information about a specific mint', + operationId: 'getMintById', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Mint details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Mint' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url': { + get: { + tags: ['Mint Details'], + summary: 'Get mint by URL', + description: 'Returns detailed information about a mint by its URL', + operationId: 'getMintByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Mint details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Mint' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/urls': { + get: { + tags: ['Mint Details'], + summary: 'Get mint URLs', + description: 'Returns all known URLs for a mint (clearnet, Tor, mirrors)', + operationId: 'getMintUrls', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Mint URLs', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintUrlsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/status': { + get: { + tags: ['Mint Details'], + summary: 'Get mint status', + description: 'Returns lightweight status information for quick checks', + operationId: 'getMintStatus', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Mint status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintStatus' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/metadata': { + get: { + tags: ['Metadata'], + summary: 'Get mint metadata', + description: 'Returns NUT-06 compliant metadata for a mint', + operationId: 'getMintMetadata', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Mint metadata', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintMetadata' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/metadata/history': { + get: { + tags: ['Metadata'], + summary: 'Get metadata history', + description: 'Returns history of metadata changes for a mint', + operationId: 'getMintMetadataHistory', + parameters: [ + { $ref: '#/components/parameters/mintId' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of history entries', + schema: { type: 'integer', default: 50 } + } + ], + responses: { + 200: { + description: 'Metadata history', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MetadataHistoryResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/uptime': { + get: { + tags: ['Uptime'], + summary: 'Get uptime statistics', + description: 'Returns uptime and reliability metrics for a mint', + operationId: 'getMintUptime', + parameters: [ + { $ref: '#/components/parameters/mintId' }, + { + name: 'window', + in: 'query', + description: 'Time window for statistics', + schema: { + type: 'string', + enum: ['24h', '7d', '30d'], + default: '24h' + } + } + ], + responses: { + 200: { + description: 'Uptime statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UptimeStats' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/uptime/timeseries': { + get: { + tags: ['Uptime'], + summary: 'Get uptime timeseries', + description: 'Returns time-bucketed uptime data for charting', + operationId: 'getMintUptimeTimeseries', + parameters: [ + { $ref: '#/components/parameters/mintId' }, + { + name: 'window', + in: 'query', + description: 'Time window', + schema: { + type: 'string', + enum: ['24h', '7d', '30d'], + default: '24h' + } + }, + { + name: 'bucket', + in: 'query', + description: 'Time bucket size', + schema: { + type: 'string', + enum: ['5m', '15m', '1h'], + default: '1h' + } + } + ], + responses: { + 200: { + description: 'Uptime timeseries', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UptimeTimeseriesResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/incidents': { + get: { + tags: ['Uptime'], + summary: 'Get incidents', + description: 'Returns downtime incidents for a mint', + operationId: 'getMintIncidents', + parameters: [ + { $ref: '#/components/parameters/mintId' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of incidents', + schema: { type: 'integer', default: 50 } + } + ], + responses: { + 200: { + description: 'Incident list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/IncidentsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/trust': { + get: { + tags: ['Trust'], + summary: 'Get trust score', + description: 'Returns the computed trust score with full breakdown', + operationId: 'getMintTrust', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Trust score', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TrustScore' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/reviews': { + get: { + tags: ['Reviews'], + summary: 'Get Nostr reviews', + description: 'Returns NIP-87 reviews from Nostr relays', + operationId: 'getMintReviews', + parameters: [ + { $ref: '#/components/parameters/mintId' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of reviews', + schema: { type: 'integer', default: 50 } + }, + { + name: 'offset', + in: 'query', + description: 'Number of reviews to skip', + schema: { type: 'integer', default: 0 } + } + ], + responses: { + 200: { + description: 'Review list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ReviewsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/views': { + get: { + tags: ['Analytics'], + summary: 'Get pageview statistics', + description: 'Returns popularity metrics based on pageviews', + operationId: 'getMintViews', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Pageview statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ViewStats' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/{mint_id}/features': { + get: { + tags: ['Mint Details'], + summary: 'Get derived features', + description: 'Returns features derived from metadata (supported NUTs, Bolt11 support, etc.)', + operationId: 'getMintFeatures', + parameters: [ + { $ref: '#/components/parameters/mintId' } + ], + responses: { + 200: { + description: 'Derived features', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintFeatures' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/urls': { + get: { + tags: ['Mint Details'], + summary: 'Get mint URLs by URL', + description: 'Returns all known URLs for a mint identified by URL', + operationId: 'getMintUrlsByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Mint URLs', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintUrlsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/status': { + get: { + tags: ['Mint Details'], + summary: 'Get mint status by URL', + description: 'Returns lightweight status information by URL', + operationId: 'getMintStatusByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Mint status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintStatus' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/metadata': { + get: { + tags: ['Metadata'], + summary: 'Get mint metadata by URL', + description: 'Returns NUT-06 compliant metadata for a mint by URL', + operationId: 'getMintMetadataByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Mint metadata', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintMetadata' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/metadata/history': { + get: { + tags: ['Metadata'], + summary: 'Get metadata history by URL', + description: 'Returns history of metadata changes for a mint by URL', + operationId: 'getMintMetadataHistoryByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of history entries', + schema: { type: 'integer', default: 50 } + } + ], + responses: { + 200: { + description: 'Metadata history', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MetadataHistoryResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/uptime': { + get: { + tags: ['Uptime'], + summary: 'Get uptime statistics by URL', + description: 'Returns uptime and reliability metrics for a mint by URL', + operationId: 'getMintUptimeByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' }, + { + name: 'window', + in: 'query', + description: 'Time window for statistics', + schema: { + type: 'string', + enum: ['24h', '7d', '30d'], + default: '24h' + } + } + ], + responses: { + 200: { + description: 'Uptime statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UptimeStats' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/uptime/timeseries': { + get: { + tags: ['Uptime'], + summary: 'Get uptime timeseries by URL', + description: 'Returns time-bucketed uptime data for charting by URL', + operationId: 'getMintUptimeTimeseriesByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' }, + { + name: 'window', + in: 'query', + description: 'Time window', + schema: { + type: 'string', + enum: ['24h', '7d', '30d'], + default: '24h' + } + }, + { + name: 'bucket', + in: 'query', + description: 'Time bucket size', + schema: { + type: 'string', + enum: ['5m', '15m', '1h'], + default: '1h' + } + } + ], + responses: { + 200: { + description: 'Uptime timeseries', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UptimeTimeseriesResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/incidents': { + get: { + tags: ['Uptime'], + summary: 'Get incidents by URL', + description: 'Returns downtime incidents for a mint by URL', + operationId: 'getMintIncidentsByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of incidents', + schema: { type: 'integer', default: 50 } + } + ], + responses: { + 200: { + description: 'Incident list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/IncidentsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/trust': { + get: { + tags: ['Trust'], + summary: 'Get trust score by URL', + description: 'Returns the computed trust score with full breakdown by URL', + operationId: 'getMintTrustByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Trust score', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/TrustScore' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/reviews': { + get: { + tags: ['Reviews'], + summary: 'Get Nostr reviews by URL', + description: 'Returns NIP-87 reviews from Nostr relays by URL', + operationId: 'getMintReviewsByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' }, + { + name: 'limit', + in: 'query', + description: 'Maximum number of reviews', + schema: { type: 'integer', default: 50 } + }, + { + name: 'offset', + in: 'query', + description: 'Number of reviews to skip', + schema: { type: 'integer', default: 0 } + } + ], + responses: { + 200: { + description: 'Review list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ReviewsResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/views': { + get: { + tags: ['Analytics'], + summary: 'Get pageview statistics by URL', + description: 'Returns popularity metrics based on pageviews by URL', + operationId: 'getMintViewsByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Pageview statistics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ViewStats' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/mints/by-url/features': { + get: { + tags: ['Mint Details'], + summary: 'Get derived features by URL', + description: 'Returns features derived from metadata by URL', + operationId: 'getMintFeaturesByUrl', + parameters: [ + { $ref: '#/components/parameters/mintUrl' } + ], + responses: { + 200: { + description: 'Derived features', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintFeatures' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + // ========================================== + // ADMIN ENDPOINTS + // ========================================== + '/admin/mints': { + post: { + tags: ['Admin'], + summary: 'Manually add a mint', + description: 'Add a mint to the system manually. Used for trusted bootstrap or recovery.', + operationId: 'adminCreateMint', + security: [{ AdminApiKey: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['mint_url'], + properties: { + mint_url: { type: 'string', format: 'uri' }, + notes: { type: 'string', description: 'Internal notes' } + } + } + } + } + }, + responses: { + 201: { + description: 'Mint created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminMintResponse' } + } + } + }, + 200: { + description: 'Mint already exists', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AdminMintResponse' } + } + } + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 403: { $ref: '#/components/responses/Forbidden' } + } + } + }, + '/admin/mints/{mint_id}/urls': { + post: { + tags: ['Admin'], + summary: 'Add URL to mint', + description: 'Attach an additional URL (clearnet, Tor, mirror) to an existing mint.', + operationId: 'adminAddMintUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', format: 'uri' }, + type: { type: 'string', enum: ['clearnet', 'tor', 'mirror'] }, + active: { type: 'boolean', default: true } + } + } + } + } + }, + responses: { + 201: { description: 'URL added' }, + 404: { $ref: '#/components/responses/NotFound' }, + 409: { description: 'URL already attached to another mint' } + } + } + }, + '/admin/mints/merge': { + post: { + tags: ['Admin'], + summary: 'Merge two mints', + description: 'Merge two mints that represent the same operator. All data is reassigned to target.', + operationId: 'adminMergeMints', + security: [{ AdminApiKey: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['source_mint_id', 'target_mint_id'], + properties: { + source_mint_id: { type: 'string', format: 'uuid' }, + target_mint_id: { type: 'string', format: 'uuid' }, + reason: { type: 'string' } + } + } + } + } + }, + responses: { + 200: { + description: 'Mints merged', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MergeResponse' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/split': { + post: { + tags: ['Admin'], + summary: 'Undo a merge', + description: 'Revert a previous mint merge operation.', + operationId: 'adminSplitMints', + security: [{ AdminApiKey: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['merge_id'], + properties: { + merge_id: { type: 'string', format: 'uuid' } + } + } + } + } + }, + responses: { + 200: { description: 'Merge reverted' }, + 404: { description: 'Merge not found' }, + 409: { description: 'Merge already reverted' } + } + } + }, + '/admin/mints/{mint_id}/disable': { + post: { + tags: ['Admin'], + summary: 'Disable mint', + description: 'Hide a mint from public listings. Mint continues to be probed.', + operationId: 'adminDisableMint', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + reason: { type: 'string' } + } + } + } + } + }, + responses: { + 200: { description: 'Mint disabled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/{mint_id}/enable': { + post: { + tags: ['Admin'], + summary: 'Enable mint', + description: 'Re-enable a previously hidden mint.', + operationId: 'adminEnableMint', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { description: 'Mint enabled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/{mint_id}/metadata/refresh': { + post: { + tags: ['Admin'], + summary: 'Force metadata refresh', + description: 'Force metadata fetch, bypassing the hourly limit.', + operationId: 'adminForceMetadata', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { description: 'Metadata refresh scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/{mint_id}/trust/recompute': { + post: { + tags: ['Admin'], + summary: 'Force trust recompute', + description: 'Force trust score recomputation using current data.', + operationId: 'adminForceTrust', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { description: 'Trust recomputation scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/{mint_id}/status/reset': { + post: { + tags: ['Admin'], + summary: 'Reset mint status', + description: 'Clear stuck mint state. Resets consecutive failures and schedules probe.', + operationId: 'adminResetStatus', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { description: 'Status reset, probe scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/jobs': { + get: { + tags: ['Admin'], + summary: 'Inspect job queue', + description: 'View background job queue status.', + operationId: 'adminGetJobs', + security: [{ AdminApiKey: [] }], + parameters: [ + { name: 'status', in: 'query', schema: { type: 'string', enum: ['pending', 'running', 'completed', 'failed'] } }, + { name: 'type', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } } + ], + responses: { + 200: { + description: 'Job list', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/JobListResponse' } + } + } + } + } + } + }, + '/admin/system/metrics': { + get: { + tags: ['Admin'], + summary: 'System metrics', + description: 'High-level system health and metrics.', + operationId: 'adminGetMetrics', + security: [{ AdminApiKey: [] }], + responses: { + 200: { + description: 'System metrics', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SystemMetrics' } + } + } + } + } + } + }, + '/admin/audit': { + get: { + tags: ['Admin'], + summary: 'Audit log', + description: 'View admin action audit log.', + operationId: 'adminGetAudit', + security: [{ AdminApiKey: [] }], + parameters: [ + { name: 'action', in: 'query', schema: { type: 'string' } }, + { name: 'target_type', in: 'query', schema: { type: 'string' } }, + { name: 'admin_id', in: 'query', schema: { type: 'string' } }, + { name: 'mint_id', in: 'query', description: 'Filter by mint ID', schema: { type: 'string', format: 'uuid' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 100 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } } + ], + responses: { + 200: { + description: 'Audit log entries', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/AuditLogResponse' } + } + } + } + } + } + }, + '/admin/mints/{mint_id}/probe': { + post: { + tags: ['Admin'], + summary: 'Force probe', + description: 'Force an immediate probe of a mint.', + operationId: 'adminForceProbeMint', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { description: 'Probe scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/{mint_id}/visibility': { + get: { + tags: ['Admin'], + summary: 'Get mint visibility', + description: 'Get the current visibility status of a mint.', + operationId: 'adminGetMintVisibility', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintId' }], + responses: { + 200: { + description: 'Visibility status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintVisibility' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/disable': { + post: { + tags: ['Admin'], + summary: 'Disable mint by URL', + description: 'Hide a mint from public listings by URL.', + operationId: 'adminDisableMintByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { reason: { type: 'string' } } + } + } + } + }, + responses: { + 200: { description: 'Mint disabled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/enable': { + post: { + tags: ['Admin'], + summary: 'Enable mint by URL', + description: 'Re-enable a hidden mint by URL.', + operationId: 'adminEnableMintByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { description: 'Mint enabled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/metadata/refresh': { + post: { + tags: ['Admin'], + summary: 'Force metadata refresh by URL', + description: 'Force metadata fetch by URL, bypassing the hourly limit.', + operationId: 'adminForceMetadataByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { description: 'Metadata refresh scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/trust/recompute': { + post: { + tags: ['Admin'], + summary: 'Force trust recompute by URL', + description: 'Force trust score recomputation by URL.', + operationId: 'adminForceTrustByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { description: 'Trust recomputation scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/status/reset': { + post: { + tags: ['Admin'], + summary: 'Reset mint status by URL', + description: 'Clear stuck mint state by URL.', + operationId: 'adminResetStatusByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { description: 'Status reset, probe scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/probe': { + post: { + tags: ['Admin'], + summary: 'Force probe by URL', + description: 'Force an immediate probe by URL.', + operationId: 'adminForceProbeByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { description: 'Probe scheduled' }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + }, + '/admin/mints/by-url/visibility': { + get: { + tags: ['Admin'], + summary: 'Get mint visibility by URL', + description: 'Get visibility status by URL.', + operationId: 'adminGetMintVisibilityByUrl', + security: [{ AdminApiKey: [] }], + parameters: [{ $ref: '#/components/parameters/mintUrl' }], + responses: { + 200: { + description: 'Visibility status', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/MintVisibility' } + } + } + }, + 404: { $ref: '#/components/responses/NotFound' } + } + } + } + }, + components: { + securitySchemes: { + AdminApiKey: { + type: 'apiKey', + in: 'header', + name: 'X-Admin-Api-Key', + description: 'Admin API key for protected endpoints' + } + }, + parameters: { + mintId: { + name: 'mint_id', + in: 'path', + required: true, + description: 'Unique mint identifier (UUID)', + schema: { + type: 'string', + format: 'uuid' + } + }, + mintUrl: { + name: 'url', + in: 'query', + required: true, + description: 'Mint URL (will be normalized)', + schema: { + type: 'string', + format: 'uri' + } + } + }, + responses: { + NotFound: { + description: 'Mint not found', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + }, + RateLimited: { + description: 'Rate limit exceeded', + headers: { + 'Retry-After': { + description: 'Seconds until rate limit resets', + schema: { type: 'integer' } + }, + 'X-RateLimit-Limit': { + description: 'Maximum requests per window', + schema: { type: 'integer' } + }, + 'X-RateLimit-Remaining': { + description: 'Remaining requests in window', + schema: { type: 'integer' } + } + }, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + }, + Unauthorized: { + description: 'Missing authentication', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + }, + Forbidden: { + description: 'Invalid authentication', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' } + } + } + } + }, + schemas: { + Error: { + type: 'object', + properties: { + error: { type: 'string', description: 'Error message' } + }, + required: ['error'] + }, + HealthResponse: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['healthy', 'unhealthy'] + }, + database: { + type: 'string', + enum: ['connected', 'disconnected'] + }, + timestamp: { + type: 'string', + format: 'date-time' + }, + version: { type: 'string' } + } + }, + StatsResponse: { + type: 'object', + properties: { + mints: { + type: 'object', + properties: { + total: { type: 'integer' }, + by_status: { + type: 'object', + additionalProperties: { type: 'integer' } + } + } + }, + activity: { + type: 'object', + properties: { + probes_24h: { type: 'integer' }, + reviews_total: { type: 'integer' }, + incidents_7d: { type: 'integer' } + } + }, + computed_at: { type: 'string', format: 'date-time' } + } + }, + Mint: { + type: 'object', + properties: { + mint_id: { + type: 'string', + format: 'uuid', + description: 'Unique mint identifier' + }, + canonical_url: { + type: 'string', + format: 'uri', + description: 'Primary mint URL' + }, + urls: { + type: 'array', + items: { type: 'string', format: 'uri' }, + description: 'All known URLs for this mint' + }, + name: { + type: 'string', + nullable: true, + description: 'Mint name from metadata' + }, + icon_url: { + type: 'string', + format: 'uri', + nullable: true, + description: 'Mint icon URL' + }, + status: { + type: 'string', + enum: ['unknown', 'online', 'degraded', 'offline', 'abandoned'], + description: 'Current mint status' + }, + offline_since: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'When the mint went offline' + }, + last_success_at: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'Last successful probe' + }, + last_failure_at: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'Last failed probe' + }, + uptime_24h: { + type: 'number', + nullable: true, + description: '24-hour uptime percentage' + }, + uptime_7d: { + type: 'number', + nullable: true, + description: '7-day uptime percentage' + }, + uptime_30d: { + type: 'number', + nullable: true, + description: '30-day uptime percentage' + }, + incidents_7d: { + type: 'integer', + description: 'Incident count in last 7 days' + }, + incidents_30d: { + type: 'integer', + description: 'Incident count in last 30 days' + }, + trust_score: { + type: 'integer', + nullable: true, + minimum: 0, + maximum: 100, + description: 'Trust score (0-100)' + }, + trust_level: { + type: 'string', + enum: ['unknown', 'low', 'medium', 'high', 'excellent'], + nullable: true, + description: 'Trust level category' + } + } + }, + MintListResponse: { + type: 'object', + properties: { + mints: { + type: 'array', + items: { $ref: '#/components/schemas/Mint' } + }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + MintSubmission: { + type: 'object', + required: ['mint_url'], + properties: { + mint_url: { + type: 'string', + format: 'uri', + description: 'URL of the Cashu mint to submit' + } + } + }, + MintSubmissionResponse: { + type: 'object', + properties: { + success: { type: 'boolean' }, + mint_id: { type: 'string', format: 'uuid' }, + status: { type: 'string' }, + message: { type: 'string' } + } + }, + MintUrlsResponse: { + type: 'object', + properties: { + canonical_url: { type: 'string', format: 'uri' }, + urls: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + type: { + type: 'string', + enum: ['clearnet', 'tor', 'mirror'] + }, + active: { type: 'boolean' }, + discovered_at: { type: 'string', format: 'date-time' }, + last_seen_at: { type: 'string', format: 'date-time' } + } + } + } + } + }, + MintStatus: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['unknown', 'online', 'degraded', 'offline', 'abandoned'] + }, + offline_since: { + type: 'string', + format: 'date-time', + nullable: true + }, + last_checked_at: { + type: 'string', + format: 'date-time', + nullable: true + }, + current_rtt_ms: { + type: 'integer', + nullable: true, + description: 'Current response time in milliseconds' + } + } + }, + MintMetadata: { + type: 'object', + description: 'NUT-06 compliant mint metadata', + properties: { + name: { type: 'string', nullable: true }, + pubkey: { + type: 'string', + nullable: true, + description: 'Mint public key (hex)' + }, + version: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + description_long: { type: 'string', nullable: true }, + contact: { + type: 'object', + nullable: true, + additionalProperties: { type: 'string' } + }, + motd: { + type: 'string', + nullable: true, + description: 'Message of the day' + }, + icon_url: { type: 'string', format: 'uri', nullable: true }, + urls: { + type: 'array', + nullable: true, + items: { type: 'string', format: 'uri' } + }, + tos_url: { + type: 'string', + format: 'uri', + nullable: true, + description: 'Terms of service URL' + }, + nuts: { + type: 'object', + nullable: true, + description: 'Supported NUTs configuration', + additionalProperties: { type: 'object' } + }, + server_time: { + type: 'string', + format: 'date-time', + nullable: true + }, + last_fetched_at: { type: 'string', format: 'date-time' } + } + }, + MetadataHistoryResponse: { + type: 'object', + properties: { + history: { + type: 'array', + items: { + type: 'object', + properties: { + fetched_at: { type: 'string', format: 'date-time' }, + change_type: { + type: 'string', + enum: ['initial', 'update', 'error'] + }, + diff: { + type: 'object', + nullable: true, + description: 'JSON diff of changes' + }, + version: { type: 'string', nullable: true } + } + } + } + } + }, + UptimeStats: { + type: 'object', + properties: { + uptime_pct: { + type: 'number', + description: 'Uptime percentage' + }, + downtime_seconds: { + type: 'integer', + description: 'Total downtime in seconds' + }, + avg_rtt_ms: { + type: 'number', + nullable: true, + description: 'Average response time' + }, + p95_rtt_ms: { + type: 'number', + nullable: true, + description: '95th percentile response time' + }, + total_checks: { type: 'integer' }, + ok_checks: { type: 'integer' } + } + }, + UptimeTimeseriesResponse: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time' }, + state: { + type: 'string', + enum: ['up', 'down', 'degraded'] + }, + ok: { type: 'integer' }, + total: { type: 'integer' }, + rtt_ms: { type: 'integer', nullable: true } + } + } + } + } + }, + IncidentsResponse: { + type: 'object', + properties: { + incidents: { + type: 'array', + items: { + type: 'object', + properties: { + started_at: { type: 'string', format: 'date-time' }, + resolved_at: { + type: 'string', + format: 'date-time', + nullable: true + }, + duration_seconds: { type: 'integer', nullable: true }, + severity: { + type: 'string', + enum: ['minor', 'major', 'critical'] + } + } + } + } + } + }, + TrustScore: { + type: 'object', + properties: { + score_total: { + type: 'integer', + minimum: 0, + maximum: 100, + nullable: true + }, + score_level: { + type: 'string', + enum: ['unknown', 'low', 'medium', 'high', 'excellent'] + }, + breakdown: { + type: 'object', + nullable: true, + description: 'Detailed score breakdown by component', + properties: { + uptime: { + type: 'object', + properties: { + score: { type: 'number' }, + max: { type: 'number' }, + details: { type: 'object' } + } + }, + speed: { + type: 'object', + properties: { + score: { type: 'number' }, + max: { type: 'number' }, + details: { type: 'object' } + } + }, + reviews: { + type: 'object', + properties: { + score: { type: 'number' }, + max: { type: 'number' }, + details: { type: 'object' } + } + }, + identity: { + type: 'object', + properties: { + score: { type: 'number' }, + max: { type: 'number' }, + details: { type: 'object' } + } + }, + penalties: { + type: 'object', + properties: { + score: { type: 'number' }, + max: { type: 'number' }, + details: { type: 'object' } + } + } + } + }, + computed_at: { + type: 'string', + format: 'date-time', + nullable: true + } + } + }, + ReviewsResponse: { + type: 'object', + properties: { + reviews: { + type: 'array', + items: { + type: 'object', + properties: { + event_id: { + type: 'string', + description: 'Nostr event ID' + }, + pubkey: { + type: 'string', + description: 'Reviewer Nostr pubkey' + }, + created_at: { type: 'string', format: 'date-time' }, + rating: { + type: 'integer', + minimum: 1, + maximum: 5, + nullable: true + }, + content: { + type: 'string', + nullable: true, + description: 'Review text' + } + } + } + } + } + }, + ViewStats: { + type: 'object', + properties: { + views_24h: { type: 'integer' }, + views_7d: { type: 'integer' }, + views_30d: { type: 'integer' }, + unique_sessions_30d: { type: 'integer' }, + view_velocity: { + type: 'number', + description: 'Average views per day' + } + } + }, + MintFeatures: { + type: 'object', + properties: { + supported_nuts: { + type: 'array', + items: { type: 'integer' }, + description: 'List of supported NUT numbers' + }, + supports_bolt11: { + type: 'boolean', + description: 'Whether Bolt11 Lightning is supported' + }, + min_amount: { + type: 'integer', + nullable: true, + description: 'Minimum transaction amount in sats' + }, + max_amount: { + type: 'integer', + nullable: true, + description: 'Maximum transaction amount in sats' + }, + has_tor_endpoint: { + type: 'boolean', + description: 'Whether a .onion URL is available' + }, + has_multiple_urls: { + type: 'boolean', + description: 'Whether multiple URLs are configured' + }, + feature_completeness_score: { + type: 'integer', + minimum: 0, + maximum: 100, + description: 'Overall feature completeness (0-100)' + } + } + }, + AdminMintResponse: { + type: 'object', + properties: { + mint_id: { type: 'string', format: 'uuid' }, + status: { type: 'string' }, + message: { type: 'string' }, + created: { type: 'boolean' } + } + }, + MergeResponse: { + type: 'object', + properties: { + merge_id: { type: 'string', format: 'uuid' }, + source_mint_id: { type: 'string', format: 'uuid' }, + target_mint_id: { type: 'string', format: 'uuid' }, + message: { type: 'string' } + } + }, + JobListResponse: { + type: 'object', + properties: { + jobs: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + type: { type: 'string' }, + status: { type: 'string', enum: ['pending', 'running', 'completed', 'failed'] }, + payload: { type: 'object' }, + run_at: { type: 'string', format: 'date-time' }, + retries: { type: 'integer' }, + error_message: { type: 'string', nullable: true } + } + } + }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + SystemMetrics: { + type: 'object', + properties: { + total_mints: { type: 'integer' }, + online_mints: { type: 'integer' }, + degraded_mints: { type: 'integer' }, + offline_mints: { type: 'integer' }, + abandoned_mints: { type: 'integer' }, + unknown_mints: { type: 'integer' }, + probes_last_minute: { type: 'integer' }, + failed_probes_last_minute: { type: 'integer' }, + job_backlog: { type: 'integer' }, + oldest_job_age_seconds: { type: 'integer' }, + worker_heartbeat_seconds: { type: 'integer', nullable: true }, + computed_at: { type: 'string', format: 'date-time' } + } + }, + AuditLogResponse: { + type: 'object', + properties: { + entries: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + admin_id: { type: 'string' }, + action: { type: 'string' }, + target_type: { type: 'string' }, + target_id: { type: 'string', nullable: true }, + before_state: { type: 'object', nullable: true }, + after_state: { type: 'object', nullable: true }, + notes: { type: 'string', nullable: true }, + ip_address: { type: 'string', nullable: true }, + created_at: { type: 'string', format: 'date-time' } + } + } + }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + GlobalReviewsResponse: { + type: 'object', + properties: { + reviews: { + type: 'array', + items: { + type: 'object', + properties: { + event_id: { type: 'string' }, + mint_id: { type: 'string', format: 'uuid', nullable: true }, + mint_name: { type: 'string', nullable: true }, + mint_url: { type: 'string', nullable: true }, + pubkey: { type: 'string' }, + created_at: { type: 'string', format: 'date-time' }, + rating: { type: 'integer', minimum: 1, maximum: 5, nullable: true }, + content: { type: 'string', nullable: true } + } + } + }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + } + }, + TrendingMintsResponse: { + type: 'object', + properties: { + window: { type: 'string', enum: ['24h', '7d'] }, + mints: { + type: 'array', + items: { + type: 'object', + properties: { + mint_id: { type: 'string', format: 'uuid' }, + canonical_url: { type: 'string' }, + name: { type: 'string', nullable: true }, + icon_url: { type: 'string', nullable: true }, + status: { type: 'string' }, + trust_score: { type: 'integer', nullable: true }, + trust_level: { type: 'string', nullable: true }, + view_count: { type: 'integer' }, + view_velocity: { type: 'number' }, + unique_sessions: { type: 'integer' } + } + } + } + } + }, + UptimeAnalytics: { + type: 'object', + properties: { + window: { type: 'string' }, + overall: { + type: 'object', + properties: { + total_probes: { type: 'integer' }, + successful_probes: { type: 'integer' }, + uptime_percent: { type: 'number', nullable: true }, + avg_rtt_ms: { type: 'number', nullable: true }, + min_rtt_ms: { type: 'number', nullable: true }, + max_rtt_ms: { type: 'number', nullable: true } + } + }, + by_status: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + mint_count: { type: 'integer' }, + probe_count: { type: 'integer' }, + successful_probes: { type: 'integer' } + } + } + }, + rtt_distribution: { + type: 'object', + properties: { + fast: { type: 'integer', description: '< 500ms' }, + normal: { type: 'integer', description: '500-1000ms' }, + slow: { type: 'integer', description: '1000-2000ms' }, + degraded: { type: 'integer', description: '> 2000ms' } + } + }, + computed_at: { type: 'string', format: 'date-time' } + } + }, + VersionAnalytics: { + type: 'object', + properties: { + versions: { + type: 'array', + items: { + type: 'object', + properties: { + version: { type: 'string' }, + count: { type: 'integer' }, + mints: { type: 'array', items: { type: 'string' } } + } + } + }, + total_with_version: { type: 'integer' }, + total_mints: { type: 'integer' }, + coverage_percent: { type: 'integer' }, + computed_at: { type: 'string', format: 'date-time' } + } + }, + NutsAnalytics: { + type: 'object', + properties: { + nuts: { + type: 'array', + items: { + type: 'object', + properties: { + nut: { type: 'integer' }, + count: { type: 'integer' }, + percent: { type: 'integer' } + } + } + }, + key_nuts: { + type: 'object', + additionalProperties: { type: 'integer' } + }, + total_mints_analyzed: { type: 'integer' }, + computed_at: { type: 'string', format: 'date-time' } + } + }, + MintVisibility: { + type: 'object', + properties: { + mint_id: { type: 'string', format: 'uuid' }, + canonical_url: { type: 'string' }, + visibility: { type: 'string', enum: ['public', 'hidden'] }, + status: { type: 'string' } + } + } + } + } +}; \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5a10017 --- /dev/null +++ b/src/index.js @@ -0,0 +1,290 @@ +/** + * Cashumints.space API + * + * Main server entry point. + * + * A decentralized observability and reputation API for the Cashu mint ecosystem. + */ + +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import swaggerUi from 'swagger-ui-express'; +import { config } from './config.js'; +import { initDatabase, closeDatabase } from './db/connection.js'; +import { rateLimit } from './middleware/rateLimit.js'; +import { notFound, errorHandler } from './middleware/errorHandler.js'; +import mintRoutes from './routes/mints.js'; +import systemRoutes from './routes/system.js'; +import adminRoutes from './routes/admin.js'; +import { openApiSpec } from './docs/openapi.js'; +import { initAdminSchema } from './services/AdminService.js'; + +// Initialize database +initDatabase(); + +// Initialize admin schema +initAdminSchema(); + +// Create Express app +const app = express(); + +// ========================================== +// MIDDLEWARE +// ========================================== + +// Compression +app.use(compression()); + +// Security headers - skip helmet entirely for docs +app.use((req, res, next) => { + if (req.path.startsWith('/docs')) { + return next(); + } + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + originAgentCluster: false + })(req, res, next); +}); + +// CORS - allow all origins for public API +app.use(cors({ + origin: '*', + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'X-Session-Id'], + exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'] +})); + +// Parse JSON bodies +app.use(express.json({ limit: '1mb' })); + +// Rate limiting (skip for docs) +app.use((req, res, next) => { + if (req.path.startsWith('/docs') || req.path === '/openapi.json') { + return next(); + } + rateLimit(req, res, next); +}); + +// Request logging +if (config.nodeEnv !== 'production' || process.env.REQUEST_LOGGING === 'true') { + app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`); + }); + next(); + }); +} + +// ========================================== +// SWAGGER DOCUMENTATION +// ========================================== + +// Serve OpenAPI spec as JSON +app.get('/openapi.json', (req, res) => { + res.json(openApiSpec); +}); + +// Swagger UI configuration +const swaggerUiOptions = { + customCss: ` + .swagger-ui .topbar { display: none } + .swagger-ui .info { margin: 30px 0 } + .swagger-ui .info .title { + font-size: 2.5em; + font-weight: 700; + color: #2d5016; + } + .swagger-ui .scheme-container { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + } + .swagger-ui .opblock.opblock-get .opblock-summary-method { + background: #2d5016; + } + .swagger-ui .opblock.opblock-post .opblock-summary-method { + background: #49cc90; + } + .swagger-ui .btn.execute { + background: #2d5016; + border-color: #2d5016; + } + .swagger-ui .btn.execute:hover { + background: #1e3810; + } + `, + customSiteTitle: 'Cashumints.space API Documentation', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + docExpansion: 'list', + filter: true, + showExtensions: true, + showCommonExtensions: true, + tryItOutEnabled: true + } +}; + +// Mount Swagger UI at /docs +app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiSpec, swaggerUiOptions)); + +// ========================================== +// ROUTES +// ========================================== + +// API version prefix +const API_PREFIX = '/v1'; + +// Health and stats +app.use(API_PREFIX, systemRoutes); +app.get('/health', (req, res) => res.redirect(`${API_PREFIX}/health`)); + +// Mint routes +app.use(`${API_PREFIX}/mints`, mintRoutes); + +// Admin routes +app.use(`${API_PREFIX}/admin`, adminRoutes); + +// Root endpoint +app.get('/', (req, res) => { + res.json({ + name: 'Cashumints.space API', + version: '1.0.0', + description: 'Decentralized observability and reputation API for Cashu mints', + documentation: '/docs', + openapi: '/openapi.json', + endpoints: { + health: `${API_PREFIX}/health`, + stats: `${API_PREFIX}/stats`, + mints: `${API_PREFIX}/mints`, + submit: `${API_PREFIX}/mints/submit` + }, + links: { + website: 'https://cashumints.space', + cashu: 'https://cashu.space', + protocol: 'https://docs.cashu.space', + nostr_nip87: 'https://github.com/nostr-protocol/nips/blob/master/87.md' + } + }); +}); + +// API info +app.get(API_PREFIX, (req, res) => { + res.json({ + version: 'v1', + documentation: '/docs', + openapi: '/openapi.json', + endpoints: [ + { method: 'GET', path: '/v1/health', description: 'Health check' }, + { method: 'GET', path: '/v1/stats', description: 'System statistics' }, + { method: 'GET', path: '/v1/mints', description: 'List all mints' }, + { method: 'GET', path: '/v1/mints/:mint_id', description: 'Get mint by ID' }, + { method: 'GET', path: '/v1/mints/by-url?url=', description: 'Get mint by URL' }, + { method: 'GET', path: '/v1/mints/:mint_id/urls', description: 'Get mint URLs' }, + { method: 'GET', path: '/v1/mints/:mint_id/metadata', description: 'Get mint metadata (NUT-06)' }, + { method: 'GET', path: '/v1/mints/:mint_id/metadata/history', description: 'Get metadata history' }, + { method: 'GET', path: '/v1/mints/:mint_id/status', description: 'Get mint status' }, + { method: 'GET', path: '/v1/mints/:mint_id/uptime', description: 'Get uptime stats' }, + { method: 'GET', path: '/v1/mints/:mint_id/uptime/timeseries', description: 'Get uptime timeseries' }, + { method: 'GET', path: '/v1/mints/:mint_id/incidents', description: 'Get incidents' }, + { method: 'GET', path: '/v1/mints/:mint_id/trust', description: 'Get trust score' }, + { method: 'GET', path: '/v1/mints/:mint_id/reviews', description: 'Get Nostr reviews' }, + { method: 'GET', path: '/v1/mints/:mint_id/views', description: 'Get pageview stats' }, + { method: 'GET', path: '/v1/mints/:mint_id/features', description: 'Get derived features' }, + { method: 'POST', path: '/v1/mints/submit', description: 'Submit a new mint' } + ], + notes: [ + 'All endpoints support both mint_id and by-url variants', + 'All timestamps are ISO-8601 UTC', + 'Rate limit: 100 requests per minute' + ] + }); +}); + +// ========================================== +// ERROR HANDLING +// ========================================== + +app.use(notFound); +app.use(errorHandler); + +// ========================================== +// SERVER STARTUP +// ========================================== + +const server = app.listen(config.port, config.host, () => { + console.log(` +╔═══════════════════════════════════════════════════════════════╗ +β•‘ β•‘ +β•‘ 🌿 Cashumints.space API β•‘ +β•‘ β•‘ +β•‘ Decentralized observability and reputation layer β•‘ +β•‘ for the Cashu mint ecosystem β•‘ +β•‘ β•‘ +╠═══════════════════════════════════════════════════════════════╣ +β•‘ β•‘ +β•‘ Server: http://${config.host}:${config.port} β•‘ +β•‘ Environment: ${config.nodeEnv.padEnd(45)}β•‘ +β•‘ β•‘ +β•‘ Documentation: β•‘ +β•‘ β€’ Swagger UI: http://${config.host}:${config.port}/docs β•‘ +β•‘ β€’ OpenAPI: http://${config.host}:${config.port}/openapi.json β•‘ +β•‘ β•‘ +β•‘ Endpoints: β•‘ +β•‘ β€’ GET /v1/health - Health check β•‘ +β•‘ β€’ GET /v1/stats - System stats β•‘ +β•‘ β€’ GET /v1/mints - List mints β•‘ +β•‘ β€’ POST /v1/mints/submit - Submit mint β•‘ +β•‘ β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + `); +}); + +// ========================================== +// GRACEFUL SHUTDOWN +// ========================================== + +function shutdown(signal) { + console.log(`\n[Server] ${signal} received. Shutting down gracefully...`); + + server.close(() => { + console.log('[Server] HTTP server closed'); + closeDatabase(); + console.log('[Server] Database connection closed'); + process.exit(0); + }); + + // Force exit after 10 seconds + setTimeout(() => { + console.error('[Server] Forced shutdown'); + process.exit(1); + }, 10000); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// Handle uncaught errors +process.on('uncaughtException', (error) => { + console.error('[Server] Uncaught exception:', error); + shutdown('UNCAUGHT_EXCEPTION'); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason); +}); + +export { app, server }; \ No newline at end of file diff --git a/src/middleware/adminAuth.js b/src/middleware/adminAuth.js new file mode 100644 index 0000000..a7038c2 --- /dev/null +++ b/src/middleware/adminAuth.js @@ -0,0 +1,97 @@ +/** + * Admin Authentication Middleware + * + * Validates ADMIN_API_KEY header for admin endpoints. + * All admin requests are logged. + */ + +import { config } from '../config.js'; +import { logAdminAction } from '../services/AdminService.js'; + +/** + * Validate admin API key + */ +export function adminAuth(req, res, next) { + // Get API key from header + let apiKey = req.headers['x-admin-api-key']; + if (!apiKey && req.headers['authorization']) { + apiKey = req.headers['authorization'].replace('Bearer ', ''); + } + + if (!config.adminApiKey) { + console.error('[AdminAuth] ADMIN_API_KEY not configured'); + return res.status(503).json({ + error: 'Admin API not configured', + message: 'Set ADMIN_API_KEY environment variable' + }); + } + + if (!apiKey) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Missing X-Admin-Api-Key header' + }); + } + + if (apiKey !== config.adminApiKey) { + // Log failed auth attempt + console.warn(`[AdminAuth] Invalid API key attempt from ${req.ip}`); + return res.status(403).json({ + error: 'Forbidden', + message: 'Invalid admin API key' + }); + } + + // Attach admin context to request + req.admin = { + id: 'admin', // Could be extended for multi-admin support + ip: req.ip || req.headers['x-forwarded-for'] || 'unknown', + userAgent: req.headers['user-agent'] || 'unknown' + }; + + next(); +} + +/** + * Audit logging middleware - runs after admin routes + */ +export function auditLog(action, targetType) { + return (req, res, next) => { + // Store original json method + const originalJson = res.json.bind(res); + + // Override json to capture response + res.json = (data) => { + // Log the admin action + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const adminId = (req.admin && req.admin.id) ? req.admin.id : 'unknown'; + const targetId = req.params.mint_id || + (req.body && req.body.mint_id) || + (req.body && req.body.source_mint_id); + const notes = (req.body && req.body.notes) || (req.body && req.body.reason); + const ipAddress = req.admin && req.admin.ip; + const userAgent = req.admin && req.admin.userAgent; + + logAdminAction({ + adminId, + action, + targetType, + targetId, + beforeState: req.beforeState, + afterState: data, + notes, + ipAddress, + userAgent + }); + } catch (err) { + console.error('[AuditLog] Failed to log action:', err); + } + } + + return originalJson(data); + }; + + next(); + }; +} \ No newline at end of file diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..7227e6c --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,43 @@ +/** + * Error Handling Middleware + */ + +import { config } from '../config.js'; + +/** + * 404 Not Found handler + */ +export function notFound(req, res, next) { + res.status(404).json({ + error: 'Not found', + path: req.path, + method: req.method + }); +} + +/** + * Global error handler + */ +export function errorHandler(err, req, res, next) { + console.error('[Error]', err); + + // Handle specific error types + if (err.type === 'entity.parse.failed') { + return res.status(400).json({ + error: 'Invalid JSON', + message: err.message + }); + } + + // Default error response + const statusCode = err.statusCode || err.status || 500; + const message = config.nodeEnv === 'production' + ? 'Internal server error' + : err.message; + + res.status(statusCode).json({ + error: message, + ...(config.nodeEnv !== 'production' && { stack: err.stack }) + }); +} + diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..bdd7f24 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,77 @@ +/** + * Rate Limiting Middleware + * + * Simple in-memory rate limiter. + * For production, consider using Redis-backed solution. + */ + +import { config } from '../config.js'; + +// In-memory store for request counts +const requestCounts = new Map(); + +// Cleanup old entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, data] of requestCounts) { + if (now - data.windowStart > config.rateLimitWindowMs * 2) { + requestCounts.delete(key); + } + } +}, 60000); // Every minute + +/** + * Get client identifier from request + */ +function getClientId(req) { + // Use X-Forwarded-For header if behind proxy + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + return req.ip || (req.connection ? req.connection.remoteAddress : null) || 'unknown'; +} + +/** + * Rate limiting middleware + */ +export function rateLimit(req, res, next) { + const clientId = getClientId(req); + const now = Date.now(); + + let data = requestCounts.get(clientId); + + if (!data || now - data.windowStart > config.rateLimitWindowMs) { + // New window + data = { + windowStart: now, + count: 1 + }; + requestCounts.set(clientId, data); + next(); + return; + } + + data.count++; + + if (data.count > config.rateLimitMaxRequests) { + const retryAfter = Math.ceil((data.windowStart + config.rateLimitWindowMs - now) / 1000); + + res.set('Retry-After', retryAfter.toString()); + res.set('X-RateLimit-Limit', config.rateLimitMaxRequests.toString()); + res.set('X-RateLimit-Remaining', '0'); + res.set('X-RateLimit-Reset', new Date(data.windowStart + config.rateLimitWindowMs).toISOString()); + + return res.status(429).json({ + error: 'Too many requests', + retry_after: retryAfter + }); + } + + // Add rate limit headers + res.set('X-RateLimit-Limit', config.rateLimitMaxRequests.toString()); + res.set('X-RateLimit-Remaining', (config.rateLimitMaxRequests - data.count).toString()); + res.set('X-RateLimit-Reset', new Date(data.windowStart + config.rateLimitWindowMs).toISOString()); + + next(); +} \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..a5729af --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,597 @@ +/** + * Admin Routes + * + * Protected admin-only endpoints for mint curation and system management. + * All actions are audited. + */ + +import { Router } from 'express'; +import { adminAuth, auditLog } from '../middleware/adminAuth.js'; +import { + adminCreateMint, + adminAddMintUrl, + mergeMints, + splitMints, + disableMint, + enableMint, + forceMetadataRefresh, + forceTrustRecompute, + resetMintStatus, + getSystemMetrics, + getAuditLog, + forceProbeMint, + getMintVisibility +} from '../services/AdminService.js'; +import { getJobs } from '../services/JobService.js'; +import { getMintById, getMintByUrl } from '../services/MintService.js'; + +const router = Router(); + +// Apply admin auth to all routes +router.use(adminAuth); + +// ========================================== +// MINT MANAGEMENT +// ========================================== + +/** + * POST /v1/admin/mints + * Manually add a mint to the system + */ +router.post('/mints', auditLog('create_mint', 'mint'), (req, res) => { + try { + const { mint_url, notes } = req.body; + + if (!mint_url) { + return res.status(400).json({ error: 'mint_url is required' }); + } + + const result = adminCreateMint(mint_url, { + notes, + adminId: req.admin.id + }); + + res.status(result.created ? 201 : 200).json(result); + } catch (error) { + console.error('[Admin] Error creating mint:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/:mint_id/urls + * Add URL to existing mint + */ +router.post('/mints/:mint_id/urls', auditLog('add_url', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + const { url, type, active = true } = req.body; + + if (!url) { + return res.status(400).json({ error: 'url is required' }); + } + + const result = adminAddMintUrl(mint_id, url, { + type, + active, + adminId: req.admin.id + }); + + res.status(201).json(result); + } catch (error) { + console.error('[Admin] Error adding URL:', error); + + if (error.message.includes('already attached')) { + return res.status(409).json({ error: error.message }); + } + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// MINT MERGE/SPLIT +// ========================================== + +/** + * POST /v1/admin/mints/merge + * Merge two mints + */ +router.post('/mints/merge', auditLog('merge_mints', 'mint'), (req, res) => { + try { + const { source_mint_id, target_mint_id, reason } = req.body; + + if (!source_mint_id || !target_mint_id) { + return res.status(400).json({ error: 'source_mint_id and target_mint_id are required' }); + } + + // Store before state for audit + req.beforeState = { + source: getMintById(source_mint_id), + target: getMintById(target_mint_id) + }; + + const result = mergeMints(source_mint_id, target_mint_id, { + reason, + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error merging mints:', error); + + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/split + * Undo a mint merge + */ +router.post('/mints/split', auditLog('split_mints', 'mint'), (req, res) => { + try { + const { merge_id } = req.body; + + if (!merge_id) { + return res.status(400).json({ error: 'merge_id is required' }); + } + + const result = splitMints(merge_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error splitting mints:', error); + + if (error.message === 'Merge not found') { + return res.status(404).json({ error: error.message }); + } + if (error.message === 'Merge already reverted') { + return res.status(409).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// VISIBILITY CONTROL +// ========================================== + +/** + * POST /v1/admin/mints/:mint_id/disable + * Hide a mint from public listings + */ +router.post('/mints/:mint_id/disable', auditLog('disable_mint', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + const { reason } = req.body; + + // Store before state + req.beforeState = getMintById(mint_id); + + const result = disableMint(mint_id, { + reason, + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error disabling mint:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/:mint_id/enable + * Re-enable a hidden mint + */ +router.post('/mints/:mint_id/enable', auditLog('enable_mint', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + + // Store before state + req.beforeState = getMintById(mint_id); + + const result = enableMint(mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error enabling mint:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// FORCE REFRESH +// ========================================== + +/** + * POST /v1/admin/mints/:mint_id/metadata/refresh + * Force metadata fetch + */ +router.post('/mints/:mint_id/metadata/refresh', auditLog('force_metadata', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + + const result = forceMetadataRefresh(mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing metadata refresh:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/:mint_id/trust/recompute + * Force trust score recomputation + */ +router.post('/mints/:mint_id/trust/recompute', auditLog('force_trust', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + + const result = forceTrustRecompute(mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing trust recompute:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/:mint_id/status/reset + * Reset stuck mint status + */ +router.post('/mints/:mint_id/status/reset', auditLog('reset_status', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + + // Store before state + req.beforeState = getMintById(mint_id); + + const result = resetMintStatus(mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error resetting status:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// SYSTEM INSPECTION +// ========================================== + +/** + * GET /v1/admin/jobs + * Inspect the job queue + */ +router.get('/jobs', (req, res) => { + try { + const { status, type, limit = 100, offset = 0 } = req.query; + + const jobs = getJobs({ + status, + type, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0 + }); + + res.json({ + jobs, + total: jobs.length, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0 + }); + } catch (error) { + console.error('[Admin] Error getting jobs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/admin/system/metrics + * System health metrics + */ +router.get('/system/metrics', (req, res) => { + try { + const metrics = getSystemMetrics(); + res.json(metrics); + } catch (error) { + console.error('[Admin] Error getting metrics:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/admin/audit + * Get audit log entries + */ +router.get('/audit', (req, res) => { + try { + const { action, target_type, admin_id, mint_id, limit = 100, offset = 0 } = req.query; + + const entries = getAuditLog({ + action, + targetType: target_type, + adminId: admin_id, + mintId: mint_id, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0 + }); + + res.json({ + entries, + total: entries.length, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0 + }); + } catch (error) { + console.error('[Admin] Error getting audit log:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// SINGLE MINT PROBE TRIGGER +// ========================================== + +/** + * POST /v1/admin/mints/:mint_id/probe + * Force probe a mint immediately + */ +router.post('/mints/:mint_id/probe', auditLog('force_probe', 'mint'), (req, res) => { + try { + const { mint_id } = req.params; + + const result = forceProbeMint(mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing probe:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// MINT VISIBILITY STATUS +// ========================================== + +/** + * GET /v1/admin/mints/:mint_id/visibility + * Get mint visibility status + */ +router.get('/mints/:mint_id/visibility', (req, res) => { + try { + const { mint_id } = req.params; + + const result = getMintVisibility(mint_id); + res.json(result); + } catch (error) { + console.error('[Admin] Error getting visibility:', error); + + if (error.message === 'Mint not found') { + return res.status(404).json({ error: error.message }); + } + + res.status(400).json({ error: error.message }); + } +}); + +// ========================================== +// BY-URL ADMIN ENDPOINTS +// ========================================== + +// Helper to resolve mint from URL +function resolveMintFromUrl(req, res) { + const { url } = req.query; + if (!url) { + res.status(400).json({ error: 'URL query parameter required' }); + return null; + } + + const mint = getMintByUrl(url); + if (!mint) { + res.status(404).json({ error: 'Mint not found' }); + return null; + } + + return mint; +} + +/** + * POST /v1/admin/mints/by-url/disable + * Disable mint by URL + */ +router.post('/mints/by-url/disable', auditLog('disable_mint', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + const { reason } = req.body; + req.beforeState = mint; + + const result = disableMint(mint.mint_id, { + reason, + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error disabling mint:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/by-url/enable + * Enable mint by URL + */ +router.post('/mints/by-url/enable', auditLog('enable_mint', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + req.beforeState = mint; + + const result = enableMint(mint.mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error enabling mint:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/by-url/metadata/refresh + * Force metadata refresh by URL + */ +router.post('/mints/by-url/metadata/refresh', auditLog('force_metadata', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + const result = forceMetadataRefresh(mint.mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing metadata refresh:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/by-url/trust/recompute + * Force trust recompute by URL + */ +router.post('/mints/by-url/trust/recompute', auditLog('force_trust', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + const result = forceTrustRecompute(mint.mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing trust recompute:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/by-url/status/reset + * Reset mint status by URL + */ +router.post('/mints/by-url/status/reset', auditLog('reset_status', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + req.beforeState = mint; + + const result = resetMintStatus(mint.mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error resetting status:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * POST /v1/admin/mints/by-url/probe + * Force probe by URL + */ +router.post('/mints/by-url/probe', auditLog('force_probe', 'mint'), (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + const result = forceProbeMint(mint.mint_id, { + adminId: req.admin.id + }); + + res.json(result); + } catch (error) { + console.error('[Admin] Error forcing probe:', error); + res.status(400).json({ error: error.message }); + } +}); + +/** + * GET /v1/admin/mints/by-url/visibility + * Get mint visibility by URL + */ +router.get('/mints/by-url/visibility', (req, res) => { + try { + const mint = resolveMintFromUrl(req, res); + if (!mint) return; + + const result = getMintVisibility(mint.mint_id); + res.json(result); + } catch (error) { + console.error('[Admin] Error getting visibility:', error); + res.status(400).json({ error: error.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/routes/mints.js b/src/routes/mints.js new file mode 100644 index 0000000..28558eb --- /dev/null +++ b/src/routes/mints.js @@ -0,0 +1,909 @@ +/** + * Mint Routes + * + * Core mint discovery and management endpoints. + * + * IMPORTANT: Route order matters! + * Static routes (like /by-url/*) must come BEFORE parameterized routes (/:mint_id/*) + */ + +import { Router } from 'express'; +import { + getMintById, + getMintByUrl, + getMints, + getMintUrls, + submitMint, + countMintsByStatus, + resolveMint +} from '../services/MintService.js'; +import { getMetadataSnapshot, getMetadataHistory, deriveFeatures } from '../services/MetadataService.js'; +import { getUptime, getUptimeTimeseries, getUptimeSummary, getLatencyTimeseries } from '../services/UptimeService.js'; +import { getIncidents, countIncidents, getLatestProbe } from '../services/ProbeService.js'; +import { getTrustScore, getTrustScoreHistory, getTrustComparison, getTrustScoreHistoryDetailed } from '../services/TrustService.js'; +import { getReviews, getReviewSummary } from '../services/ReviewService.js'; +import { getPageviewStats, recordPageview, getTrendingMints, getViewsTimeseries } from '../services/PageviewService.js'; +import { daysAgo } from '../utils/time.js'; +import { getAllReviews, countReviews } from '../services/ReviewService.js'; +import { + getMintActivity, + getRecentMints, + getUpdatedMints, + getPopularMints, + getMintStats, + getMintAvailability, + getMintCard +} from '../services/AnalyticsService.js'; + +const router = Router(); + +// ========================================== +// HELPER: Resolve mint by URL query param +// ========================================== + +function getMintFromUrl(req, res) { + const { url } = req.query; + if (!url) { + res.status(400).json({ error: 'URL parameter required' }); + return null; + } + + const mint = getMintByUrl(url); + if (!mint) { + res.status(404).json({ error: 'Mint not found' }); + return null; + } + + return mint; +} + +// ========================================== +// HELPER: Resolve mint middleware for ID routes +// ========================================== + +function resolveMintMiddleware(req, res, next) { + const { mint_id } = req.params; + + if (!mint_id) { + return res.status(400).json({ error: 'Mint ID required' }); + } + + const mint = resolveMint(mint_id); + if (!mint) { + return res.status(404).json({ error: 'Mint not found' }); + } + + req.mint = mint; + next(); +} + +// ========================================== +// LIST & SUBMIT (no param conflicts) +// ========================================== + +/** + * GET /v1/mints + * List all mints with optional filters + */ +router.get('/', (req, res) => { + try { + const { status, limit = 100, offset = 0, sort_by, sort_order } = req.query; + + const mints = getMints({ + status, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0, + sortBy: sort_by, + sortOrder: sort_order + }); + + // Enrich with uptime summary + const enrichedMints = mints.map(mint => ({ + ...mint, + ...getUptimeSummary(mint.mint_id), + incidents_7d: countIncidents(mint.mint_id, daysAgo(7)), + incidents_30d: countIncidents(mint.mint_id, daysAgo(30)) + })); + + res.json({ + mints: enrichedMints, + total: enrichedMints.length, + limit: Math.min(parseInt(limit) || 100, 500), + offset: parseInt(offset) || 0 + }); + } catch (error) { + console.error('[API] Error listing mints:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /v1/mints/submit + */ +router.post('/submit', (req, res) => { + try { + const { mint_url } = req.body; + + if (!mint_url) { + return res.status(400).json({ error: 'mint_url required' }); + } + + const result = submitMint(mint_url); + res.status(result.success ? 201 : 400).json(result); + } catch (error) { + console.error('[API] Error submitting mint:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// HOMEPAGE & DISCOVERY ENDPOINTS +// ========================================== + +/** + * GET /v1/mints/activity + * Get mint ecosystem activity overview + */ +router.get('/activity', (req, res) => { + try { + const activity = getMintActivity(); + res.json(activity); + } catch (error) { + console.error('[API] Error getting mint activity:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/recent + * Get recently added mints + */ +router.get('/recent', (req, res) => { + try { + const { window = '7d', limit = 20 } = req.query; + const validWindows = ['24h', '7d', '30d']; + const timeWindow = validWindows.includes(window) ? window : '7d'; + + const mints = getRecentMints(timeWindow, Math.min(parseInt(limit) || 20, 100)); + res.json({ + window: timeWindow, + mints + }); + } catch (error) { + console.error('[API] Error getting recent mints:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/updated + * Get recently updated mints + */ +router.get('/updated', (req, res) => { + try { + const { limit = 20 } = req.query; + const mints = getUpdatedMints(Math.min(parseInt(limit) || 20, 100)); + res.json({ mints }); + } catch (error) { + console.error('[API] Error getting updated mints:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/popular + * Get popular mints by views + */ +router.get('/popular', async(req, res) => { + try { + const { window = '7d', limit = 20 } = req.query; + const validWindows = ['24h', '7d', '30d']; + const timeWindow = validWindows.includes(window) ? window : '7d'; + + const mints = await getPopularMints(timeWindow, Math.min(parseInt(limit) || 20, 100)); + res.json({ + window: timeWindow, + mints + }); + } catch (error) { + console.error('[API] Error getting popular mints:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/trending + * Get trending mints by view velocity + */ +router.get('/trending', async(req, res) => { + try { + const { window = '7d', limit = 10 } = req.query; + + // Validate window + const validWindows = ['24h', '7d']; + const timeWindow = validWindows.includes(window) ? window : '7d'; + + const trending = await getTrendingMints( + Math.min(parseInt(limit) || 10, 50), + timeWindow + ); + + res.json({ + window: timeWindow, + mints: trending.map(m => ({ + mint_id: m.mint_id, + canonical_url: m.canonical_url, + name: m.name, + icon_url: m.icon_url, + status: m.status, + trust_score: m.trust_score, + trust_level: m.trust_level, + view_count: m.view_count, + view_velocity: m.view_velocity, + unique_sessions: m.unique_sessions + })) + }); + } catch (error) { + console.error('[API] Error getting trending mints:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// BY-URL ROUTES (must come BEFORE :mint_id routes) +// ========================================== + +/** + * GET /v1/mints/by-url + */ +router.get('/by-url', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const uptime = getUptimeSummary(mint.mint_id); + res.json({ + mint_id: mint.mint_id, + canonical_url: mint.canonical_url, + urls: getMintUrls(mint.mint_id).map(u => u.url), + name: mint.name, + icon_url: mint.icon_url, + status: mint.status, + offline_since: mint.offline_since, + last_success_at: mint.last_success_at, + last_failure_at: mint.last_failure_at, + ...uptime, + incidents_7d: countIncidents(mint.mint_id, daysAgo(7)), + incidents_30d: countIncidents(mint.mint_id, daysAgo(30)), + trust_score: mint.trust_score, + trust_level: mint.trust_level + }); + } catch (error) { + console.error('[API] Error getting mint by URL:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/urls + */ +router.get('/by-url/urls', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const urls = getMintUrls(mint.mint_id); + res.json({ + canonical_url: mint.canonical_url, + urls + }); + } catch (error) { + console.error('[API] Error getting mint URLs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/metadata + */ +router.get('/by-url/metadata', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const metadata = getMetadataSnapshot(mint.mint_id); + if (!metadata) { + return res.json({ error: 'No metadata available', mint_id: mint.mint_id }); + } + + res.json({ + name: metadata.name, + pubkey: metadata.pubkey, + version: metadata.version, + description: metadata.description, + description_long: metadata.description_long, + contact: metadata.contact, + motd: metadata.motd, + icon_url: metadata.icon_url, + urls: metadata.urls, + tos_url: metadata.tos_url, + nuts: metadata.nuts, + server_time: metadata.server_time, + last_fetched_at: metadata.last_fetched_at + }); + } catch (error) { + console.error('[API] Error getting metadata:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/metadata/history + */ +router.get('/by-url/metadata/history', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const { limit = 50 } = req.query; + const history = getMetadataHistory(mint.mint_id, { limit: parseInt(limit) }); + res.json({ history }); + } catch (error) { + console.error('[API] Error getting metadata history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/status + */ +router.get('/by-url/status', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const latestProbe = getLatestProbe(mint.mint_id); + res.json({ + status: mint.status, + offline_since: mint.offline_since, + last_checked_at: latestProbe ? latestProbe.probed_at : null, + current_rtt_ms: latestProbe ? latestProbe.rtt_ms : null + }); + } catch (error) { + console.error('[API] Error getting status:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/uptime + */ +router.get('/by-url/uptime', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const { window = '24h' } = req.query; + const uptime = getUptime(mint.mint_id, window); + if (!uptime) { + return res.json({ error: 'No uptime data available' }); + } + + res.json(uptime); + } catch (error) { + console.error('[API] Error getting uptime:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/uptime/timeseries + */ +router.get('/by-url/uptime/timeseries', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const { window = '24h', bucket = '1h' } = req.query; + const timeseries = getUptimeTimeseries(mint.mint_id, window, bucket); + res.json({ data: timeseries }); + } catch (error) { + console.error('[API] Error getting uptime timeseries:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/incidents + */ +router.get('/by-url/incidents', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const { limit = 50 } = req.query; + const incidents = getIncidents(mint.mint_id, { limit: parseInt(limit) }); + res.json({ incidents }); + } catch (error) { + console.error('[API] Error getting incidents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/trust + */ +router.get('/by-url/trust', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const trust = getTrustScore(mint.mint_id); + if (!trust) { + return res.json({ + score_total: null, + score_level: 'unknown', + breakdown: null, + computed_at: null + }); + } + + res.json(trust); + } catch (error) { + console.error('[API] Error getting trust score:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/reviews + */ +router.get('/by-url/reviews', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const { limit = 50, offset = 0 } = req.query; + const reviews = getReviews(mint.mint_id, { + limit: parseInt(limit), + offset: parseInt(offset) + }); + + res.json({ reviews }); + } catch (error) { + console.error('[API] Error getting reviews:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/views + */ +router.get('/by-url/views', async(req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const stats = await getPageviewStats(mint.mint_id); + res.json(stats); + } catch (error) { + console.error('[API] Error getting views:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/by-url/features + */ +router.get('/by-url/features', (req, res) => { + try { + const mint = getMintFromUrl(req, res); + if (!mint) return; + + const features = deriveFeatures(mint.mint_id); + res.json(features); + } catch (error) { + console.error('[API] Error getting features:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// PARAMETERIZED ROUTES (:mint_id) +// These must come AFTER /by-url/* routes +// ========================================== + +/** + * GET /v1/mints/:mint_id + */ +router.get('/:mint_id', resolveMintMiddleware, (req, res) => { + try { + const { mint } = req; + + // Enrich with computed fields + const uptime = getUptimeSummary(mint.mint_id); + const response = { + mint_id: mint.mint_id, + canonical_url: mint.canonical_url, + urls: getMintUrls(mint.mint_id).map(u => u.url), + name: mint.name, + icon_url: mint.icon_url, + status: mint.status, + offline_since: mint.offline_since, + last_success_at: mint.last_success_at, + last_failure_at: mint.last_failure_at, + ...uptime, + incidents_7d: countIncidents(mint.mint_id, daysAgo(7)), + incidents_30d: countIncidents(mint.mint_id, daysAgo(30)), + trust_score: mint.trust_score, + trust_level: mint.trust_level + }; + + // Record pageview + recordPageview(mint.mint_id, { + sessionId: req.headers['x-session-id'], + userAgent: req.headers['user-agent'], + referer: req.headers['referer'] + }); + + res.json(response); + } catch (error) { + console.error('[API] Error getting mint:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/urls + */ +router.get('/:mint_id/urls', resolveMintMiddleware, (req, res) => { + try { + const urls = getMintUrls(req.mint.mint_id); + + res.json({ + canonical_url: req.mint.canonical_url, + urls + }); + } catch (error) { + console.error('[API] Error getting mint URLs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/metadata + */ +router.get('/:mint_id/metadata', resolveMintMiddleware, (req, res) => { + try { + const metadata = getMetadataSnapshot(req.mint.mint_id); + + if (!metadata) { + return res.json({ error: 'No metadata available', mint_id: req.mint.mint_id }); + } + + res.json({ + name: metadata.name, + pubkey: metadata.pubkey, + version: metadata.version, + description: metadata.description, + description_long: metadata.description_long, + contact: metadata.contact, + motd: metadata.motd, + icon_url: metadata.icon_url, + urls: metadata.urls, + tos_url: metadata.tos_url, + nuts: metadata.nuts, + server_time: metadata.server_time, + last_fetched_at: metadata.last_fetched_at + }); + } catch (error) { + console.error('[API] Error getting metadata:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/metadata/history + */ +router.get('/:mint_id/metadata/history', resolveMintMiddleware, (req, res) => { + try { + const { limit = 50 } = req.query; + const history = getMetadataHistory(req.mint.mint_id, { limit: parseInt(limit) }); + + res.json({ history }); + } catch (error) { + console.error('[API] Error getting metadata history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/status + */ +router.get('/:mint_id/status', resolveMintMiddleware, (req, res) => { + try { + const { mint } = req; + const latestProbe = getLatestProbe(mint.mint_id); + + res.json({ + status: mint.status, + offline_since: mint.offline_since, + last_checked_at: latestProbe ? latestProbe.probed_at : null, + current_rtt_ms: latestProbe ? latestProbe.rtt_ms : null + }); + } catch (error) { + console.error('[API] Error getting status:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/uptime + */ +router.get('/:mint_id/uptime', resolveMintMiddleware, (req, res) => { + try { + const { window = '24h' } = req.query; + const uptime = getUptime(req.mint.mint_id, window); + + if (!uptime) { + return res.json({ error: 'No uptime data available' }); + } + + res.json(uptime); + } catch (error) { + console.error('[API] Error getting uptime:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/uptime/timeseries + */ +router.get('/:mint_id/uptime/timeseries', resolveMintMiddleware, (req, res) => { + try { + const { window = '24h', bucket = '1h' } = req.query; + const timeseries = getUptimeTimeseries(req.mint.mint_id, window, bucket); + + res.json({ data: timeseries }); + } catch (error) { + console.error('[API] Error getting uptime timeseries:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/incidents + */ +router.get('/:mint_id/incidents', resolveMintMiddleware, (req, res) => { + try { + const { limit = 50 } = req.query; + const incidents = getIncidents(req.mint.mint_id, { limit: parseInt(limit) }); + + res.json({ incidents }); + } catch (error) { + console.error('[API] Error getting incidents:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/trust + */ +router.get('/:mint_id/trust', resolveMintMiddleware, (req, res) => { + try { + const trust = getTrustScore(req.mint.mint_id); + + if (!trust) { + return res.json({ + score_total: null, + score_level: 'unknown', + breakdown: null, + computed_at: null + }); + } + + res.json(trust); + } catch (error) { + console.error('[API] Error getting trust score:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/reviews + */ +router.get('/:mint_id/reviews', resolveMintMiddleware, (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query; + const reviews = getReviews(req.mint.mint_id, { + limit: parseInt(limit), + offset: parseInt(offset) + }); + + res.json({ reviews }); + } catch (error) { + console.error('[API] Error getting reviews:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/views + */ +router.get('/:mint_id/views', resolveMintMiddleware, async(req, res) => { + try { + const stats = await getPageviewStats(req.mint.mint_id); + res.json(stats); + } catch (error) { + console.error('[API] Error getting views:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/features + */ +router.get('/:mint_id/features', resolveMintMiddleware, (req, res) => { + try { + const features = deriveFeatures(req.mint.mint_id); + res.json(features); + } catch (error) { + console.error('[API] Error getting features:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// MINT DETAIL AGGREGATED ENDPOINTS +// ========================================== + +/** + * GET /v1/mints/:mint_id/stats + * Aggregated mint KPIs (single endpoint for summary cards) + */ +router.get('/:mint_id/stats', resolveMintMiddleware, async(req, res) => { + try { + const stats = await getMintStats(req.mint.mint_id); + res.json(stats); + } catch (error) { + console.error('[API] Error getting mint stats:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/latency/timeseries + * Response time history for charting + */ +router.get('/:mint_id/latency/timeseries', resolveMintMiddleware, (req, res) => { + try { + const { window = '24h', bucket = '1h' } = req.query; + const validWindows = ['24h', '7d', '30d']; + const validBuckets = ['5m', '15m', '1h']; + + const timeWindow = validWindows.includes(window) ? window : '24h'; + const timeBucket = validBuckets.includes(bucket) ? bucket : '1h'; + + const data = getLatencyTimeseries(req.mint.mint_id, timeWindow, timeBucket); + res.json({ + window: timeWindow, + bucket: timeBucket, + data + }); + } catch (error) { + console.error('[API] Error getting latency timeseries:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/availability + * Availability breakdown (online/degraded/offline percentages) + */ +router.get('/:mint_id/availability', resolveMintMiddleware, (req, res) => { + try { + const { window = '30d' } = req.query; + const validWindows = ['24h', '7d', '30d']; + const timeWindow = validWindows.includes(window) ? window : '30d'; + + const availability = getMintAvailability(req.mint.mint_id, timeWindow); + res.json(availability); + } catch (error) { + console.error('[API] Error getting availability:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/trust/history + * Trust score history with change reasons + */ +router.get('/:mint_id/trust/history', resolveMintMiddleware, (req, res) => { + try { + const { limit = 30 } = req.query; + const history = getTrustScoreHistoryDetailed( + req.mint.mint_id, + Math.min(parseInt(limit) || 30, 100) + ); + res.json({ history }); + } catch (error) { + console.error('[API] Error getting trust history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/trust/compare + * Compare trust score against ecosystem benchmarks + */ +router.get('/:mint_id/trust/compare', resolveMintMiddleware, (req, res) => { + try { + const { against = 'ecosystem' } = req.query; + const validBenchmarks = ['ecosystem', 'top10', 'median']; + const benchmark = validBenchmarks.includes(against) ? against : 'ecosystem'; + + const comparison = getTrustComparison(req.mint.mint_id, benchmark); + res.json(comparison); + } catch (error) { + console.error('[API] Error getting trust comparison:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/reviews/summary + * Quick review overview (rating distribution, averages) + */ +router.get('/:mint_id/reviews/summary', resolveMintMiddleware, (req, res) => { + try { + const summary = getReviewSummary(req.mint.mint_id); + res.json(summary); + } catch (error) { + console.error('[API] Error getting review summary:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/views/timeseries + * Pageview history for adoption trends + */ +router.get('/:mint_id/views/timeseries', resolveMintMiddleware, async(req, res) => { + try { + const { window = '7d', bucket = '1d' } = req.query; + const validWindows = ['7d', '30d']; + const validBuckets = ['1h', '1d']; + + const timeWindow = validWindows.includes(window) ? window : '7d'; + const timeBucket = validBuckets.includes(bucket) ? bucket : '1d'; + + const data = await getViewsTimeseries(req.mint.mint_id, timeWindow, timeBucket); + res.json({ + window: timeWindow, + bucket: timeBucket, + data + }); + } catch (error) { + console.error('[API] Error getting views timeseries:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/mints/:mint_id/card + * Optimized endpoint for grid/list views + */ +router.get('/:mint_id/card', resolveMintMiddleware, (req, res) => { + try { + const card = getMintCard(req.mint.mint_id); + if (!card) { + return res.status(404).json({ error: 'Mint not found' }); + } + res.json(card); + } catch (error) { + console.error('[API] Error getting mint card:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/routes/system.js b/src/routes/system.js new file mode 100644 index 0000000..f11b937 --- /dev/null +++ b/src/routes/system.js @@ -0,0 +1,362 @@ +/** + * System Routes + * + * Health checks and system statistics endpoints. + */ + +import { Router } from 'express'; +import { query, queryOne } from '../db/connection.js'; +import { countMintsByStatus, getMintByUrl } from '../services/MintService.js'; +import { getPendingJobCounts } from '../services/JobService.js'; +import { getRecentReviews, getAllReviews, countReviews } from '../services/ReviewService.js'; +import { getTrendingMints } from '../services/PageviewService.js'; +import { + getUptimeAnalytics, + getVersionAnalytics, + getNutsAnalytics, + getStatusDistribution, + getNetworkBreakdown, + getMetadataQuality +} from '../services/AnalyticsService.js'; +import { getRecentEcosystemReviews } from '../services/ReviewService.js'; +import { nowISO } from '../utils/time.js'; + +const router = Router(); + +/** + * GET /v1/health + * Health check endpoint + */ +router.get('/health', (req, res) => { + try { + // Quick database check + const dbCheck = queryOne('SELECT 1 as ok'); + + if (!dbCheck) { + return res.status(503).json({ + status: 'unhealthy', + database: 'disconnected', + timestamp: nowISO() + }); + } + + res.json({ + status: 'healthy', + database: 'connected', + timestamp: nowISO(), + version: '1.0.0' + }); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + error: error.message, + timestamp: nowISO() + }); + } +}); + +/** + * GET /v1/stats + * System statistics + */ +router.get('/stats', (req, res) => { + try { + // Mint counts by status + const mintCounts = countMintsByStatus(); + const mintStats = {}; + let totalMints = 0; + + for (const { status, count } + of mintCounts) { + mintStats[status] = count; + totalMints += count; + } + + // Recent activity + const probeResult = queryOne(` + SELECT COUNT(*) as count FROM probes + WHERE probed_at >= datetime('now', '-24 hours') + `); + const probeCount = probeResult ? probeResult.count : 0; + + const reviewResult = queryOne(` + SELECT COUNT(*) as count FROM reviews + `); + const reviewCount = reviewResult ? reviewResult.count : 0; + + const incidentResult = queryOne(` + SELECT COUNT(*) as count FROM incidents + WHERE started_at >= datetime('now', '-7 days') + `); + const incidentCount = incidentResult ? incidentResult.count : 0; + + // Pending jobs + const pendingJobs = getPendingJobCounts(); + const jobStats = {}; + for (const { type, count } + of pendingJobs) { + jobStats[type] = count; + } + + // Recent reviews + const recentReviews = getRecentReviews(5); + + // Trending mints + const trending = getTrendingMints(5); + + res.json({ + mints: { + total: totalMints, + by_status: mintStats + }, + activity: { + probes_24h: probeCount, + reviews_total: reviewCount, + incidents_7d: incidentCount + }, + jobs: { + pending: jobStats + }, + recent_reviews: recentReviews, + trending_mints: trending.map(m => ({ + mint_id: m.mint_id, + name: m.name, + views_7d: m.views_7d + })), + computed_at: nowISO() + }); + } catch (error) { + console.error('[API] Error getting stats:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/stats/timeline + * Activity timeline + */ +router.get('/stats/timeline', (req, res) => { + try { + const { days = 7 } = req.query; + const numDays = Math.min(parseInt(days) || 7, 30); + + // Get probe counts by day + const probeTimeline = query(` + SELECT + date(probed_at) as date, + COUNT(*) as total, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful + FROM probes + WHERE probed_at >= datetime('now', '-${numDays} days') + GROUP BY date(probed_at) + ORDER BY date ASC + `); + + // Get incident counts by day + const incidentTimeline = query(` + SELECT + date(started_at) as date, + COUNT(*) as count + FROM incidents + WHERE started_at >= datetime('now', '-${numDays} days') + GROUP BY date(started_at) + ORDER BY date ASC + `); + + // Get review counts by day + const reviewTimeline = query(` + SELECT + date(datetime(created_at, 'unixepoch')) as date, + COUNT(*) as count + FROM reviews + WHERE created_at >= strftime('%s', 'now', '-${numDays} days') + GROUP BY date(datetime(created_at, 'unixepoch')) + ORDER BY date ASC + `); + + res.json({ + probes: probeTimeline, + incidents: incidentTimeline, + reviews: reviewTimeline, + days: numDays, + computed_at: nowISO() + }); + } catch (error) { + console.error('[API] Error getting timeline:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// GLOBAL REVIEWS FEED +// ========================================== + +/** + * GET /v1/reviews + * Global reviews feed with filtering + */ +router.get('/reviews', (req, res) => { + try { + const { mint_id, mint_url, since, until, limit = 50, offset = 0 } = req.query; + + // Resolve mint_id from URL if provided + let resolvedMintId = mint_id; + if (!resolvedMintId && mint_url) { + const mint = getMintByUrl(mint_url); + if (mint) { + resolvedMintId = mint.mint_id; + } + } + + // Parse timestamps (Unix epoch) + const sinceTs = since ? parseInt(since, 10) : undefined; + const untilTs = until ? parseInt(until, 10) : undefined; + + const reviews = getAllReviews({ + mintId: resolvedMintId, + since: sinceTs, + until: untilTs, + limit: Math.min(parseInt(limit) || 50, 200), + offset: parseInt(offset) || 0 + }); + + const total = countReviews({ + mintId: resolvedMintId, + since: sinceTs, + until: untilTs + }); + + res.json({ + reviews, + total, + limit: Math.min(parseInt(limit) || 50, 200), + offset: parseInt(offset) || 0 + }); + } catch (error) { + console.error('[API] Error getting reviews:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// ECOSYSTEM ANALYTICS +// ========================================== + +/** + * GET /v1/analytics/uptime + * Ecosystem-wide uptime analytics + */ +router.get('/analytics/uptime', (req, res) => { + try { + const { window = '24h' } = req.query; + const validWindows = ['24h', '7d', '30d']; + const timeWindow = validWindows.includes(window) ? window : '24h'; + + const analytics = getUptimeAnalytics(timeWindow); + res.json(analytics); + } catch (error) { + console.error('[API] Error getting uptime analytics:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/analytics/versions + * Mint version distribution analytics + */ +router.get('/analytics/versions', (req, res) => { + try { + const analytics = getVersionAnalytics(); + res.json(analytics); + } catch (error) { + console.error('[API] Error getting version analytics:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/analytics/nuts + * NUT support analytics across all mints + */ +router.get('/analytics/nuts', (req, res) => { + try { + const analytics = getNutsAnalytics(); + res.json(analytics); + } catch (error) { + console.error('[API] Error getting NUT analytics:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/analytics/status-distribution + * Status distribution across all mints + */ +router.get('/analytics/status-distribution', (req, res) => { + try { + const distribution = getStatusDistribution(); + res.json(distribution); + } catch (error) { + console.error('[API] Error getting status distribution:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/analytics/networks + * Network type breakdown (clearnet, tor, dual-stack) + */ +router.get('/analytics/networks', (req, res) => { + try { + const networks = getNetworkBreakdown(); + res.json(networks); + } catch (error) { + console.error('[API] Error getting network breakdown:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /v1/analytics/metadata-quality + * Metadata completeness leaderboard + */ +router.get('/analytics/metadata-quality', (req, res) => { + try { + const { limit = 50 } = req.query; + const quality = getMetadataQuality(Math.min(parseInt(limit) || 50, 200)); + res.json({ + mints: quality, + total: quality.length + }); + } catch (error) { + console.error('[API] Error getting metadata quality:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ========================================== +// REVIEWS FEED +// ========================================== + +/** + * GET /v1/reviews/recent + * Recent ecosystem-wide reviews + */ +router.get('/reviews/recent', (req, res) => { + try { + const { limit = 20, since } = req.query; + const sinceTs = since ? parseInt(since, 10) : null; + + const reviews = getRecentEcosystemReviews( + Math.min(parseInt(limit) || 20, 100), + sinceTs + ); + + res.json({ reviews }); + } catch (error) { + console.error('[API] Error getting recent reviews:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/services/AdminService.js b/src/services/AdminService.js new file mode 100644 index 0000000..c455292 --- /dev/null +++ b/src/services/AdminService.js @@ -0,0 +1,751 @@ +/** + * Admin Service + * + * Handles admin-only operations including: + * - Manual mint management + * - Mint merging/splitting + * - Visibility control + * - Force refresh operations + * - Audit logging + */ + +import { v4 as uuidv4 } from 'uuid'; +import { query, queryOne, run, transaction, getDb } from '../db/connection.js'; +import { nowISO } from '../utils/time.js'; +import { normalizeUrl, getUrlType, isValidUrl } from '../utils/url.js'; +import { getMintById, getMintByUrl, createMint, addMintUrl, updateMintStatus } from './MintService.js'; +import { createJob } from './JobService.js'; + +// ============================================ +// SCHEMA MIGRATION FOR ADMIN TABLES +// ============================================ + +export function initAdminSchema() { + const db = getDb(); + + // Create audit log table + db.exec(` + CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + before_state TEXT, + after_state TEXT, + notes TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + // Create merge tracking table + db.exec(` + CREATE TABLE IF NOT EXISTS mint_merges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + merge_id TEXT NOT NULL UNIQUE, + source_mint_id TEXT NOT NULL, + target_mint_id TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'active', + merged_at TEXT NOT NULL DEFAULT (datetime('now')), + reverted_at TEXT, + admin_id TEXT NOT NULL, + affected_urls TEXT, + affected_probes TEXT, + affected_reviews TEXT, + affected_metadata TEXT + ) + `); + + // Add visibility column to mints if not exists + try { + db.exec(`ALTER TABLE mints ADD COLUMN visibility TEXT DEFAULT 'public'`); + } catch (e) { + // Column already exists + } + + // Add discovered_from column to mints if not exists + try { + db.exec(`ALTER TABLE mints ADD COLUMN discovered_from TEXT DEFAULT 'auto'`); + } catch (e) { + // Column already exists + } + + // Add source column to mint_urls if not exists + try { + db.exec(`ALTER TABLE mint_urls ADD COLUMN source TEXT DEFAULT 'auto'`); + } catch (e) { + // Column already exists + } + + console.log('[AdminService] Admin schema initialized'); +} + +// ============================================ +// AUDIT LOGGING +// ============================================ + +/** + * Log an admin action + */ +export function logAdminAction(params) { + const { + adminId, + action, + targetType, + targetId = null, + beforeState = null, + afterState = null, + notes = null, + ipAddress = null, + userAgent = null + } = params; + + run(` + INSERT INTO admin_audit_log + (admin_id, action, target_type, target_id, before_state, after_state, notes, ip_address, user_agent, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + adminId, + action, + targetType, + targetId, + beforeState ? JSON.stringify(beforeState) : null, + afterState ? JSON.stringify(afterState) : null, + notes, + ipAddress, + userAgent, + nowISO() + ]); +} + +/** + * Get audit log entries + */ +export function getAuditLog(options = {}) { + const { limit = 100, offset = 0, action, targetType, adminId, mintId } = options; + + let sql = 'SELECT * FROM admin_audit_log WHERE 1=1'; + const params = []; + + if (action) { + sql += ' AND action = ?'; + params.push(action); + } + + if (targetType) { + sql += ' AND target_type = ?'; + params.push(targetType); + } + + if (adminId) { + sql += ' AND admin_id = ?'; + params.push(adminId); + } + + if (mintId) { + sql += ' AND target_id = ?'; + params.push(mintId); + } + + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return query(sql, params).map(row => ({ + ...row, + before_state: row.before_state ? JSON.parse(row.before_state) : null, + after_state: row.after_state ? JSON.parse(row.after_state) : null + })); +} + +/** + * Force probe a mint immediately + */ +export function forceProbeMint(mintId, options = {}) { + const { adminId } = options; + + const mint = getMintById(mintId); + if (!mint) { + throw new Error('Mint not found'); + } + + // Create high-priority probe job + createJob('probe', { mint_id: mintId, priority: 'high' }, { priority: 10 }); + + logAdminAction({ + adminId, + action: 'force_probe', + targetType: 'mint', + targetId: mintId + }); + + return { + mint_id: mintId, + canonical_url: mint.canonical_url, + message: 'Probe scheduled' + }; +} + +/** + * Get mint visibility status + */ +export function getMintVisibility(mintId) { + const mint = getMintById(mintId); + if (!mint) { + throw new Error('Mint not found'); + } + + return { + mint_id: mintId, + canonical_url: mint.canonical_url, + visibility: mint.visibility || 'public', + status: mint.status + }; +} + +// ============================================ +// MINT MANAGEMENT +// ============================================ + +/** + * Manually add a mint (admin) + */ +export function adminCreateMint(mintUrl, options = {}) { + const { notes, adminId = 'admin' } = options; + + if (!isValidUrl(mintUrl)) { + throw new Error('Invalid mint URL'); + } + + const normalizedUrl = normalizeUrl(mintUrl); + + // Check if already exists + const existing = getMintByUrl(mintUrl); + if (existing) { + return { + mint_id: existing.mint_id, + status: existing.status, + message: 'Mint already exists', + created: false + }; + } + + // Create new mint + const mintId = uuidv4(); + const now = nowISO(); + const urlType = getUrlType(mintUrl); + + return transaction(() => { + // Create mint with admin flag + run(` + INSERT INTO mints (mint_id, canonical_url, status, discovered_from, visibility, created_at, updated_at) + VALUES (?, ?, 'unknown', 'manual', 'public', ?, ?) + `, [mintId, normalizedUrl, now, now]); + + // Create URL entry + run(` + INSERT INTO mint_urls (mint_id, url, url_normalized, type, active, source, discovered_at) + VALUES (?, ?, ?, ?, 1, 'admin', ?) + `, [mintId, mintUrl, normalizedUrl, urlType, now]); + + // Enqueue immediate probe job + createJob('probe', { mint_id: mintId, priority: 'high' }, { priority: 10 }); + + // Log action + logAdminAction({ + adminId, + action: 'create_mint', + targetType: 'mint', + targetId: mintId, + afterState: { mint_id: mintId, url: mintUrl }, + notes + }); + + return { + mint_id: mintId, + status: 'unknown', + message: 'Mint added and scheduled for probing', + created: true + }; + }); +} + +/** + * Add URL to existing mint (admin) + */ +export function adminAddMintUrl(mintId, url, options = {}) { + const { type = null, active = true, adminId = 'admin' } = options; + + const mint = getMintById(mintId); + if (!mint) { + throw new Error('Mint not found'); + } + + if (!isValidUrl(url)) { + throw new Error('Invalid URL'); + } + + const normalizedUrl = normalizeUrl(url); + const urlType = type || getUrlType(url); + + // Check if URL exists elsewhere + const existingUrl = queryOne(` + SELECT mint_id FROM mint_urls WHERE url_normalized = ? + `, [normalizedUrl]); + + if (existingUrl) { + if (existingUrl.mint_id === mintId) { + throw new Error('URL already attached to this mint'); + } + throw new Error('URL already attached to another mint'); + } + + const now = nowISO(); + + return transaction(() => { + run(` + INSERT INTO mint_urls (mint_id, url, url_normalized, type, active, source, discovered_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, 'admin', ?, ?) + `, [mintId, url, normalizedUrl, urlType, active ? 1 : 0, now, now]); + + // Enqueue probe for new URL + createJob('probe', { mint_id: mintId, url }, { priority: 5 }); + + // Log action + logAdminAction({ + adminId, + action: 'add_url', + targetType: 'mint', + targetId: mintId, + afterState: { url, type: urlType, active } + }); + + return { + mint_id: mintId, + url, + type: urlType, + active, + message: 'URL added successfully' + }; + }); +} + +// ============================================ +// MINT MERGE/SPLIT +// ============================================ + +/** + * Merge two mints + */ +export function mergeMints(sourceMintId, targetMintId, options = {}) { + const { reason, adminId = 'admin' } = options; + + if (sourceMintId === targetMintId) { + throw new Error('Cannot merge a mint with itself'); + } + + const sourceMint = getMintById(sourceMintId); + const targetMint = getMintById(targetMintId); + + if (!sourceMint) throw new Error('Source mint not found'); + if (!targetMint) throw new Error('Target mint not found'); + + const mergeId = uuidv4(); + const now = nowISO(); + + return transaction(() => { + // Track affected data + const affectedUrls = query('SELECT id FROM mint_urls WHERE mint_id = ?', [sourceMintId]).map(r => r.id); + const affectedReviews = query('SELECT id FROM reviews WHERE mint_id = ?', [sourceMintId]).map(r => r.id); + const affectedMetadata = query('SELECT id FROM metadata_history WHERE mint_id = ?', [sourceMintId]).map(r => r.id); + + // Get probe ID range + const probeRange = queryOne(` + SELECT MIN(id) as min_id, MAX(id) as max_id + FROM probes WHERE mint_id = ? + `, [sourceMintId]); + + // Reassign URLs + run('UPDATE mint_urls SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign probes + run('UPDATE probes SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign metadata history + run('UPDATE metadata_history SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign reviews + run('UPDATE reviews SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign incidents + run('UPDATE incidents SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign trust scores + run('UPDATE trust_scores SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign pageviews + run('UPDATE pageviews SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Reassign uptime rollups + run('UPDATE uptime_rollups SET mint_id = ? WHERE mint_id = ?', [targetMintId, sourceMintId]); + + // Mark source mint as merged + run(` + UPDATE mints SET status = 'merged', visibility = 'hidden', updated_at = ? + WHERE mint_id = ? + `, [now, sourceMintId]); + + // Create merge record + run(` + INSERT INTO mint_merges + (merge_id, source_mint_id, target_mint_id, reason, status, merged_at, admin_id, + affected_urls, affected_probes, affected_reviews, affected_metadata) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?) + `, [ + mergeId, + sourceMintId, + targetMintId, + reason, + now, + adminId, + JSON.stringify(affectedUrls), + JSON.stringify(probeRange), + JSON.stringify(affectedReviews), + JSON.stringify(affectedMetadata) + ]); + + // Log action + logAdminAction({ + adminId, + action: 'merge_mints', + targetType: 'mint', + targetId: targetMintId, + beforeState: { source: sourceMint, target: targetMint }, + afterState: { merge_id: mergeId }, + notes: reason + }); + + // Recompute trust score for target + createJob('trust_score', { mint_id: targetMintId }, { priority: 5 }); + + return { + merge_id: mergeId, + source_mint_id: sourceMintId, + target_mint_id: targetMintId, + message: 'Mints merged successfully' + }; + }); +} + +/** + * Split (undo) a merge + */ +export function splitMints(mergeId, options = {}) { + const { adminId = 'admin' } = options; + + const merge = queryOne('SELECT * FROM mint_merges WHERE merge_id = ?', [mergeId]); + if (!merge) throw new Error('Merge not found'); + if (merge.status === 'reverted') throw new Error('Merge already reverted'); + + const now = nowISO(); + + return transaction(() => { + const affectedUrls = JSON.parse(merge.affected_urls || '[]'); + const affectedReviews = JSON.parse(merge.affected_reviews || '[]'); + const affectedMetadata = JSON.parse(merge.affected_metadata || '[]'); + const probeRange = JSON.parse(merge.affected_probes || '{}'); + + // Restore URLs + if (affectedUrls.length > 0) { + run(`UPDATE mint_urls SET mint_id = ? WHERE id IN (${affectedUrls.join(',')})`, [merge.source_mint_id]); + } + + // Restore probes + if (probeRange.min_id && probeRange.max_id) { + run(`UPDATE probes SET mint_id = ? WHERE id >= ? AND id <= ? AND mint_id = ?`, [merge.source_mint_id, probeRange.min_id, probeRange.max_id, merge.target_mint_id]); + } + + // Restore metadata history + if (affectedMetadata.length > 0) { + run(`UPDATE metadata_history SET mint_id = ? WHERE id IN (${affectedMetadata.join(',')})`, [merge.source_mint_id]); + } + + // Restore reviews + if (affectedReviews.length > 0) { + run(`UPDATE reviews SET mint_id = ? WHERE id IN (${affectedReviews.join(',')})`, [merge.source_mint_id]); + } + + // Restore source mint status + run(` + UPDATE mints SET status = 'unknown', visibility = 'public', updated_at = ? + WHERE mint_id = ? + `, [now, merge.source_mint_id]); + + // Mark merge as reverted + run(` + UPDATE mint_merges SET status = 'reverted', reverted_at = ? + WHERE merge_id = ? + `, [now, mergeId]); + + // Log action + logAdminAction({ + adminId, + action: 'split_mints', + targetType: 'mint', + targetId: merge.source_mint_id, + beforeState: { merge_id: mergeId }, + afterState: { status: 'reverted' } + }); + + // Recompute trust scores + createJob('trust_score', { mint_id: merge.source_mint_id }, { priority: 5 }); + createJob('trust_score', { mint_id: merge.target_mint_id }, { priority: 5 }); + + return { + merge_id: mergeId, + source_mint_id: merge.source_mint_id, + target_mint_id: merge.target_mint_id, + message: 'Merge reverted successfully' + }; + }); +} + +// ============================================ +// VISIBILITY CONTROL +// ============================================ + +/** + * Disable (hide) a mint + */ +export function disableMint(mintId, options = {}) { + const { adminId = 'admin', reason } = options; + + const mint = getMintById(mintId); + if (!mint) throw new Error('Mint not found'); + + const now = nowISO(); + + run(`UPDATE mints SET visibility = 'hidden', updated_at = ? WHERE mint_id = ?`, [now, mintId]); + + logAdminAction({ + adminId, + action: 'disable_mint', + targetType: 'mint', + targetId: mintId, + beforeState: { visibility: mint.visibility || 'public' }, + afterState: { visibility: 'hidden' }, + notes: reason + }); + + return { + mint_id: mintId, + visibility: 'hidden', + message: 'Mint disabled (hidden from listings)' + }; +} + +/** + * Enable (show) a mint + */ +export function enableMint(mintId, options = {}) { + const { adminId = 'admin' } = options; + + const mint = getMintById(mintId); + if (!mint) throw new Error('Mint not found'); + + const now = nowISO(); + + run(`UPDATE mints SET visibility = 'public', updated_at = ? WHERE mint_id = ?`, [now, mintId]); + + logAdminAction({ + adminId, + action: 'enable_mint', + targetType: 'mint', + targetId: mintId, + beforeState: { visibility: mint.visibility || 'hidden' }, + afterState: { visibility: 'public' } + }); + + return { + mint_id: mintId, + visibility: 'public', + message: 'Mint enabled (visible in listings)' + }; +} + +// ============================================ +// FORCE REFRESH +// ============================================ + +/** + * Force metadata refresh + */ +export function forceMetadataRefresh(mintId, options = {}) { + const { adminId = 'admin' } = options; + + const mint = getMintById(mintId); + if (!mint) throw new Error('Mint not found'); + + // Enqueue high-priority metadata job + createJob('metadata', { mint_id: mintId, force: true }, { priority: 10 }); + + logAdminAction({ + adminId, + action: 'force_metadata_refresh', + targetType: 'mint', + targetId: mintId + }); + + return { + mint_id: mintId, + message: 'Metadata refresh scheduled' + }; +} + +/** + * Force trust score recomputation + */ +export function forceTrustRecompute(mintId, options = {}) { + const { adminId = 'admin' } = options; + + const mint = getMintById(mintId); + if (!mint) throw new Error('Mint not found'); + + // Enqueue high-priority trust job + createJob('trust_score', { mint_id: mintId, force: true }, { priority: 10 }); + + logAdminAction({ + adminId, + action: 'force_trust_recompute', + targetType: 'mint', + targetId: mintId + }); + + return { + mint_id: mintId, + message: 'Trust score recomputation scheduled' + }; +} + +/** + * Reset stuck mint status + */ +export function resetMintStatus(mintId, options = {}) { + const { adminId = 'admin' } = options; + + const mint = getMintById(mintId); + if (!mint) throw new Error('Mint not found'); + + const beforeState = { + status: mint.status, + consecutive_failures: mint.consecutive_failures, + offline_since: mint.offline_since + }; + + const now = nowISO(); + + run(` + UPDATE mints + SET consecutive_failures = 0, offline_since = NULL, updated_at = ? + WHERE mint_id = ? + `, [now, mintId]); + + // Enqueue immediate probe + createJob('probe', { mint_id: mintId, priority: 'high' }, { priority: 10 }); + + logAdminAction({ + adminId, + action: 'reset_status', + targetType: 'mint', + targetId: mintId, + beforeState, + afterState: { consecutive_failures: 0, offline_since: null } + }); + + return { + mint_id: mintId, + message: 'Status reset, probe scheduled' + }; +} + +// ============================================ +// SYSTEM METRICS +// ============================================ + +/** + * Helper to safely get count from query result + */ +function getCount(result) { + if (result && typeof result.count === 'number') { + return result.count; + } + return 0; +} + +/** + * Get system metrics + */ +export function getSystemMetrics() { + const totalMints = getCount(queryOne('SELECT COUNT(*) as count FROM mints')); + + const mintsByStatus = query(` + SELECT status, COUNT(*) as count FROM mints GROUP BY status + `); + + const statusCounts = {}; + for (const row of mintsByStatus) { + statusCounts[row.status] = row.count; + } + + const probesLastMinute = getCount(queryOne(` + SELECT COUNT(*) as count FROM probes + WHERE probed_at >= datetime('now', '-1 minute') + `)); + + const failedProbesLastMinute = getCount(queryOne(` + SELECT COUNT(*) as count FROM probes + WHERE probed_at >= datetime('now', '-1 minute') AND success = 0 + `)); + + const jobBacklog = getCount(queryOne(` + SELECT COUNT(*) as count FROM jobs WHERE status = 'pending' + `)); + + const oldestJob = queryOne(` + SELECT run_at FROM jobs WHERE status = 'pending' ORDER BY run_at ASC LIMIT 1 + `); + + let oldestJobAge = 0; + if (oldestJob && oldestJob.run_at) { + oldestJobAge = Math.floor((Date.now() - new Date(oldestJob.run_at).getTime()) / 1000); + } + + // Worker heartbeat (check for recent completed jobs) + const recentCompletedJob = queryOne(` + SELECT completed_at FROM jobs + WHERE status = 'completed' + ORDER BY completed_at DESC LIMIT 1 + `); + + let workerHeartbeat = null; + if (recentCompletedJob && recentCompletedJob.completed_at) { + workerHeartbeat = Math.floor((Date.now() - new Date(recentCompletedJob.completed_at).getTime()) / 1000); + } + + return { + total_mints: totalMints, + online_mints: statusCounts.online || 0, + degraded_mints: statusCounts.degraded || 0, + offline_mints: statusCounts.offline || 0, + abandoned_mints: statusCounts.abandoned || 0, + unknown_mints: statusCounts.unknown || 0, + probes_last_minute: probesLastMinute, + failed_probes_last_minute: failedProbesLastMinute, + job_backlog: jobBacklog, + oldest_job_age_seconds: oldestJobAge, + worker_heartbeat_seconds: workerHeartbeat, + computed_at: nowISO() + }; +} \ No newline at end of file diff --git a/src/services/AnalyticsService.js b/src/services/AnalyticsService.js new file mode 100644 index 0000000..a912271 --- /dev/null +++ b/src/services/AnalyticsService.js @@ -0,0 +1,788 @@ +/** + * Analytics Service + * + * Ecosystem-wide analytics and aggregate metrics. + * Uses Plausible Analytics for pageview-based stats when configured. + */ + +import { query, queryOne } from '../db/connection.js'; +import { nowISO, daysAgo, hoursAgo } from '../utils/time.js'; +import { getUptimeSummary } from './UptimeService.js'; +import { + isPlausibleConfigured, + getTopMints as plausibleGetTopMints, + getAggregateStats, + getRealtimeVisitors, +} from './PlausibleService.js'; + +// ========================================== +// HOMEPAGE & DISCOVERY +// ========================================== + +/** + * Get mint activity overview (for homepage) + */ +export function getMintActivity() { + const now = new Date(); + const yesterday = new Date(now - 24 * 60 * 60 * 1000).toISOString(); + const weekAgo = daysAgo(7); + + // Mints that went online in last 24h + const onlineLast24h = queryOne(` + SELECT COUNT(*) as count FROM mints + WHERE status = 'online' + AND last_success_at >= ? + AND (offline_since IS NOT NULL OR last_failure_at < last_success_at) + `, [yesterday]); + + // Mints that went offline in last 24h + const offlineLast24h = queryOne(` + SELECT COUNT(*) as count FROM mints + WHERE status = 'offline' + AND offline_since >= ? + `, [yesterday]); + + // Mints that recovered in last 24h (were offline, now online) + const recoveredLast24h = queryOne(` + SELECT COUNT(*) as count FROM incidents + WHERE resolved_at >= ? + `, [yesterday]); + + // New mints in last 7 days + const newMints7d = queryOne(` + SELECT COUNT(*) as count FROM mints + WHERE created_at >= ? + AND (visibility IS NULL OR visibility = 'public') + `, [weekAgo]); + + // Updated mints in last 24h (metadata changes) + const updatedMints24h = queryOne(` + SELECT COUNT(DISTINCT mint_id) as count FROM metadata_history + WHERE fetched_at >= ? AND change_type = 'update' + `, [yesterday]); + + return { + mints_online_last_24h: (onlineLast24h && onlineLast24h.count) || 0, + mints_offline_last_24h: (offlineLast24h && offlineLast24h.count) || 0, + mints_recovered_last_24h: (recoveredLast24h && recoveredLast24h.count) || 0, + new_mints_7d: (newMints7d && newMints7d.count) || 0, + updated_mints_24h: (updatedMints24h && updatedMints24h.count) || 0, + computed_at: nowISO() + }; +} + +/** + * Get recently added mints + */ +export function getRecentMints(window = '7d', limit = 20) { + const since = window === '24h' ? hoursAgo(24) : + window === '30d' ? daysAgo(30) : daysAgo(7); + + return query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + m.icon_url, + m.status, + m.created_at as discovered_at, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.created_at >= ? + AND (m.visibility IS NULL OR m.visibility = 'public') + AND m.status != 'merged' + ORDER BY m.created_at DESC + LIMIT ? + `, [since, limit]); +} + +/** + * Get recently updated mints + */ +export function getUpdatedMints(limit = 20) { + const yesterday = hoursAgo(24); + + // Get metadata updates + const metadataUpdates = query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + 'metadata' as update_type, + mh.fetched_at as updated_at + FROM metadata_history mh + JOIN mints m ON mh.mint_id = m.mint_id + WHERE mh.fetched_at >= ? + AND mh.change_type = 'update' + AND (m.visibility IS NULL OR m.visibility = 'public') + ORDER BY mh.fetched_at DESC + LIMIT ? + `, [yesterday, limit]); + + // Get status changes (recoveries) + const statusUpdates = query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + CASE + WHEN i.resolved_at IS NOT NULL THEN 'recovery' + ELSE 'status' + END as update_type, + COALESCE(i.resolved_at, m.updated_at) as updated_at + FROM mints m + LEFT JOIN incidents i ON m.mint_id = i.mint_id AND i.resolved_at >= ? + WHERE m.updated_at >= ? + AND (m.visibility IS NULL OR m.visibility = 'public') + AND (m.status IN ('online', 'degraded') OR i.resolved_at IS NOT NULL) + ORDER BY updated_at DESC + LIMIT ? + `, [yesterday, yesterday, limit]); + + // Merge and sort + const all = [...metadataUpdates, ...statusUpdates] + .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) + .slice(0, limit); + + // Remove duplicates by mint_id, keeping most recent + const seen = new Set(); + return all.filter(item => { + if (seen.has(item.mint_id)) return false; + seen.add(item.mint_id); + return true; + }); +} + +/** + * Get popular mints by views + * Uses Plausible if configured, falls back to local DB + */ +export async function getPopularMints(window = '7d', limit = 20) { + const period = window === '24h' ? 'day' : window === '30d' ? '30d' : '7d'; + + // Try Plausible first + if (isPlausibleConfigured()) { + try { + const topMints = await plausibleGetTopMints(period, limit * 2); + + if (topMints && topMints.length > 0) { + // Get mint details from local DB + const mintIds = topMints.map(m => m.mint_id); + const placeholders = mintIds.map(() => '?').join(','); + + const mints = query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id IN (${placeholders}) + AND (m.visibility IS NULL OR m.visibility = 'public') + AND m.status != 'merged' + `, mintIds); + + // Merge Plausible data with mint details + const mintMap = new Map(mints.map(m => [m.mint_id, m])); + + return topMints + .filter(pm => mintMap.has(pm.mint_id)) + .slice(0, limit) + .map(pm => { + const mint = mintMap.get(pm.mint_id); + return { + ...mint, + views: pm.pageviews, + unique_sessions: pm.visitors, + source: 'plausible', + }; + }); + } + } catch (error) { + console.error('[Analytics] Plausible popular mints error, falling back to local:', error.message); + } + } + + // Fallback to local DB + return getLocalPopularMints(window, limit); +} + +/** + * Get popular mints from local database (fallback) + */ +function getLocalPopularMints(window = '7d', limit = 20) { + const since = window === '24h' ? hoursAgo(24) : + window === '30d' ? daysAgo(30) : daysAgo(7); + + return query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level, + COUNT(p.id) as views, + COUNT(DISTINCT p.session_id) as unique_sessions + FROM mints m + LEFT JOIN pageviews p ON m.mint_id = p.mint_id AND p.viewed_at >= ? + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') + AND m.status != 'merged' + GROUP BY m.mint_id + HAVING views > 0 + ORDER BY views DESC + LIMIT ? + `, [since, limit]); +} + +// ========================================== +// STATUS & NETWORK DISTRIBUTION +// ========================================== + +/** + * Get status distribution across all mints + */ +export function getStatusDistribution() { + const distribution = query(` + SELECT + status, + COUNT(*) as count + FROM mints + WHERE (visibility IS NULL OR visibility = 'public') + AND status != 'merged' + GROUP BY status + `); + + const result = { + online: 0, + degraded: 0, + offline: 0, + abandoned: 0, + unknown: 0 + }; + + for (const row of distribution) { + if (row.status in result) { + result[row.status] = row.count; + } + } + + return { + ...result, + total: Object.values(result).reduce((a, b) => a + b, 0), + computed_at: nowISO() + }; +} + +/** + * Get network type breakdown + */ +export function getNetworkBreakdown() { + // Get all mint URLs with their types + const urlStats = query(` + SELECT + mu.mint_id, + mu.type, + mu.url + FROM mint_urls mu + JOIN mints m ON mu.mint_id = m.mint_id + WHERE mu.active = 1 + AND (m.visibility IS NULL OR m.visibility = 'public') + AND m.status != 'merged' + `); + + // Group by mint to determine network types + const mintNetworks = new Map(); + + for (const row of urlStats) { + if (!mintNetworks.has(row.mint_id)) { + mintNetworks.set(row.mint_id, { clearnet: false, tor: false }); + } + const mint = mintNetworks.get(row.mint_id); + + if (row.type === 'tor' || row.url.includes('.onion')) { + mint.tor = true; + } else { + mint.clearnet = true; + } + } + + let torOnly = 0; + let clearnetOnly = 0; + let dualStack = 0; + + for (const [, networks] of mintNetworks) { + if (networks.tor && networks.clearnet) { + dualStack++; + } else if (networks.tor) { + torOnly++; + } else if (networks.clearnet) { + clearnetOnly++; + } + } + + // Check for testnet mints (by URL pattern) + const testnetMints = queryOne(` + SELECT COUNT(DISTINCT m.mint_id) as count + FROM mints m + JOIN mint_urls mu ON m.mint_id = mu.mint_id + WHERE mu.active = 1 + AND (mu.url LIKE '%testnut%' OR mu.url LIKE '%test%' OR mu.url LIKE '%signet%') + AND (m.visibility IS NULL OR m.visibility = 'public') + `); + + const mainnetMints = queryOne(` + SELECT COUNT(*) as count FROM mints + WHERE (visibility IS NULL OR visibility = 'public') + AND status != 'merged' + `); + + return { + mainnet_mints: ((mainnetMints && mainnetMints.count) || 0) - ((testnetMints && testnetMints.count) || 0), + testnet_mints: (testnetMints && testnetMints.count) || 0, + tor_only_mints: torOnly, + clearnet_only_mints: clearnetOnly, + dual_stack_mints: dualStack, + computed_at: nowISO() + }; +} + +/** + * Get metadata completeness leaderboard + */ +export function getMetadataQuality(limit = 50) { + const mints = query(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + ms.name as meta_name, + ms.pubkey, + ms.version, + ms.description, + ms.description_long, + ms.contact, + ms.icon_url, + ms.tos_url, + ms.motd + FROM mints m + LEFT JOIN metadata_snapshots ms ON m.mint_id = ms.mint_id + WHERE (m.visibility IS NULL OR m.visibility = 'public') + AND m.status IN ('online', 'degraded') + ORDER BY m.name + `); + + // Calculate completeness score for each mint + const scored = mints.map(m => { + let score = 0; + let maxScore = 10; + + if (m.meta_name) score += 1; + if (m.pubkey) score += 2; + if (m.version) score += 1; + if (m.description) score += 2; + if (m.description_long) score += 1; + if (m.contact) score += 1; + if (m.icon_url) score += 1; + if (m.tos_url) score += 1; + + return { + mint_id: m.mint_id, + name: m.meta_name || m.name, + canonical_url: m.canonical_url, + completeness_score: Math.round((score / maxScore) * 100), + has_name: !!m.meta_name, + has_pubkey: !!m.pubkey, + has_description: !!m.description, + has_contact: !!m.contact, + has_icon: !!m.icon_url, + has_tos: !!m.tos_url + }; + }); + + // Sort by completeness score descending + return scored + .sort((a, b) => b.completeness_score - a.completeness_score) + .slice(0, limit); +} + +/** + * Get ecosystem-wide uptime analytics + */ +export function getUptimeAnalytics(window = '24h') { + const since = window === '24h' ? hoursAgo(24) : + window === '7d' ? daysAgo(7) : + daysAgo(30); + + // Overall uptime stats + const overall = queryOne(` + SELECT + COUNT(*) as total_probes, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful_probes, + AVG(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as avg_rtt_ms, + MIN(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as min_rtt_ms, + MAX(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as max_rtt_ms + FROM probes + WHERE probed_at >= ? + `, [since]); + + // Per-status breakdown + const byStatus = query(` + SELECT + m.status, + COUNT(DISTINCT m.mint_id) as mint_count, + COUNT(p.id) as probe_count, + SUM(CASE WHEN p.success = 1 THEN 1 ELSE 0 END) as successful_probes + FROM mints m + LEFT JOIN probes p ON m.mint_id = p.mint_id AND p.probed_at >= ? + GROUP BY m.status + `, [since]); + + // Response time distribution + const rttDistribution = queryOne(` + SELECT + SUM(CASE WHEN rtt_ms < 500 THEN 1 ELSE 0 END) as fast, + SUM(CASE WHEN rtt_ms >= 500 AND rtt_ms < 1000 THEN 1 ELSE 0 END) as normal, + SUM(CASE WHEN rtt_ms >= 1000 AND rtt_ms < 2000 THEN 1 ELSE 0 END) as slow, + SUM(CASE WHEN rtt_ms >= 2000 THEN 1 ELSE 0 END) as degraded + FROM probes + WHERE probed_at >= ? AND success = 1 + `, [since]); + + const uptimePercent = overall && overall.total_probes > 0 ? + Math.round((overall.successful_probes / overall.total_probes) * 10000) / 100 : + null; + + return { + window, + overall: { + total_probes: overall ? overall.total_probes : 0, + successful_probes: overall ? overall.successful_probes : 0, + uptime_percent: uptimePercent, + avg_rtt_ms: overall ? Math.round(overall.avg_rtt_ms || 0) : null, + min_rtt_ms: overall ? overall.min_rtt_ms : null, + max_rtt_ms: overall ? overall.max_rtt_ms : null + }, + by_status: byStatus.reduce((acc, row) => { + acc[row.status || 'unknown'] = { + mint_count: row.mint_count, + probe_count: row.probe_count, + successful_probes: row.successful_probes + }; + return acc; + }, {}), + rtt_distribution: rttDistribution || { fast: 0, normal: 0, slow: 0, degraded: 0 }, + computed_at: nowISO() + }; +} + +/** + * Get version analytics across all mints + */ +export function getVersionAnalytics() { + // Get version distribution + const versions = query(` + SELECT + ms.version, + COUNT(*) as count, + GROUP_CONCAT(m.canonical_url) as mints + FROM metadata_snapshots ms + JOIN mints m ON ms.mint_id = m.mint_id + WHERE ms.version IS NOT NULL + AND m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + GROUP BY ms.version + ORDER BY count DESC + `); + + // Parse and categorize versions + const versionStats = versions.map(v => ({ + version: v.version, + count: v.count, + mints: v.mints ? v.mints.split(',').slice(0, 5) : [] // Limit to 5 examples + })); + + // Total mints with version info + const totalWithVersion = queryOne(` + SELECT COUNT(*) as count + FROM metadata_snapshots ms + JOIN mints m ON ms.mint_id = m.mint_id + WHERE ms.version IS NOT NULL + AND m.status IN ('online', 'degraded') + `); + + const totalMints = queryOne(` + SELECT COUNT(*) as count FROM mints + WHERE status IN ('online', 'degraded') + `); + + return { + versions: versionStats, + total_with_version: totalWithVersion ? totalWithVersion.count : 0, + total_mints: totalMints ? totalMints.count : 0, + coverage_percent: totalMints && totalMints.count > 0 ? + Math.round((totalWithVersion.count / totalMints.count) * 100) : 0, + computed_at: nowISO() + }; +} + +/** + * Get NUT support analytics across all mints + */ +export function getNutsAnalytics() { + // Get all metadata with nuts info + const metadataRows = query(` + SELECT + m.mint_id, + m.canonical_url, + ms.nuts + FROM metadata_snapshots ms + JOIN mints m ON ms.mint_id = m.mint_id + WHERE ms.nuts IS NOT NULL + AND m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + `); + + // Count NUT support + const nutCounts = {}; + let totalMints = 0; + + for (const row of metadataRows) { + try { + const nuts = typeof row.nuts === 'string' ? JSON.parse(row.nuts) : row.nuts; + if (nuts && typeof nuts === 'object') { + totalMints++; + for (const nutNum of Object.keys(nuts)) { + const nut = parseInt(nutNum, 10); + if (!isNaN(nut)) { + nutCounts[nut] = (nutCounts[nut] || 0) + 1; + } + } + } + } catch (e) { + // Skip invalid JSON + } + } + + // Format results + const nutStats = Object.entries(nutCounts) + .map(([nut, count]) => ({ + nut: parseInt(nut, 10), + count, + percent: Math.round((count / totalMints) * 100) + })) + .sort((a, b) => a.nut - b.nut); + + // Key NUT adoption + const keyNuts = { + 'NUT-04 (mint tokens)': nutCounts[4] || 0, + 'NUT-05 (melt tokens)': nutCounts[5] || 0, + 'NUT-07 (token state)': nutCounts[7] || 0, + 'NUT-08 (overpaid fee)': nutCounts[8] || 0, + 'NUT-10 (spending conditions)': nutCounts[10] || 0, + 'NUT-11 (P2PK)': nutCounts[11] || 0, + 'NUT-12 (DLEQ proofs)': nutCounts[12] || 0 + }; + + return { + nuts: nutStats, + key_nuts: keyNuts, + total_mints_analyzed: totalMints, + computed_at: nowISO() + }; +} + +// ========================================== +// MINT DETAIL HELPERS +// ========================================== + +/** + * Get aggregated mint stats (single endpoint for mint detail page) + * Uses Plausible for view counts when configured + */ +export async function getMintStats(mintId) { + // Get uptime data + const uptime24h = queryOne(` + SELECT uptime_pct FROM uptime_rollups + WHERE mint_id = ? AND window = '24h' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + const uptime7d = queryOne(` + SELECT uptime_pct FROM uptime_rollups + WHERE mint_id = ? AND window = '7d' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + const uptime30d = queryOne(` + SELECT uptime_pct, avg_rtt_ms FROM uptime_rollups + WHERE mint_id = ? AND window = '30d' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + // Get incident count + const incidents30d = queryOne(` + SELECT COUNT(*) as count FROM incidents + WHERE mint_id = ? AND started_at >= ? + `, [mintId, daysAgo(30)]); + + // Get last incident + const lastIncident = queryOne(` + SELECT started_at FROM incidents + WHERE mint_id = ? + ORDER BY started_at DESC LIMIT 1 + `, [mintId]); + + // Get trust score + const trust = queryOne(` + SELECT score_total, score_level FROM current_trust_scores + WHERE mint_id = ? + `, [mintId]); + + // Get review count + const reviews = queryOne(` + SELECT COUNT(*) as count FROM reviews + WHERE mint_id = ? + `, [mintId]); + + // Get views - try Plausible first + let views30d = 0; + let viewsSource = 'local'; + + if (isPlausibleConfigured()) { + try { + const { getMintPageviewStats } = await + import ('./PlausibleService.js'); + const plausibleStats = await getMintPageviewStats(mintId, '30d'); + if (plausibleStats && plausibleStats.pageviews) { + views30d = plausibleStats.pageviews.value || 0; + viewsSource = 'plausible'; + } + } catch (error) { + console.error('[Analytics] Plausible stats error:', error.message); + } + } + + // Fallback to local DB + if (viewsSource === 'local') { + const localViews = queryOne(` + SELECT COUNT(*) as count FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, daysAgo(30)]); + views30d = (localViews && localViews.count) || 0; + } + + return { + uptime_24h: (uptime24h && uptime24h.uptime_pct) || null, + uptime_7d: (uptime7d && uptime7d.uptime_pct) || null, + uptime_30d: (uptime30d && uptime30d.uptime_pct) || null, + avg_rtt_ms: (uptime30d && uptime30d.avg_rtt_ms) ? Math.round(uptime30d.avg_rtt_ms) : null, + incidents_30d: (incidents30d && incidents30d.count) || 0, + last_incident_at: (lastIncident && lastIncident.started_at) || null, + trust_score: (trust && trust.score_total) || null, + trust_level: (trust && trust.score_level) || 'unknown', + review_count: (reviews && reviews.count) || 0, + views_30d: views30d, + views_source: viewsSource, + computed_at: nowISO() + }; +} + +/** + * Get mint availability breakdown (percentage by status) + */ +export function getMintAvailability(mintId, window = '30d') { + const since = window === '24h' ? hoursAgo(24) : + window === '7d' ? daysAgo(7) : daysAgo(30); + + const stats = queryOne(` + SELECT + COUNT(*) as total_checks, + SUM(CASE WHEN success = 1 AND rtt_ms < 2000 THEN 1 ELSE 0 END) as online_checks, + SUM(CASE WHEN success = 1 AND rtt_ms >= 2000 THEN 1 ELSE 0 END) as degraded_checks, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as offline_checks + FROM probes + WHERE mint_id = ? AND probed_at >= ? + `, [mintId, since]); + + if (!stats || stats.total_checks === 0) { + return { + online_percentage: null, + degraded_percentage: null, + offline_percentage: null, + total_checks: 0, + window, + computed_at: nowISO() + }; + } + + return { + online_percentage: Math.round((stats.online_checks / stats.total_checks) * 10000) / 100, + degraded_percentage: Math.round((stats.degraded_checks / stats.total_checks) * 10000) / 100, + offline_percentage: Math.round((stats.offline_checks / stats.total_checks) * 10000) / 100, + total_checks: stats.total_checks, + window, + computed_at: nowISO() + }; +} + +/** + * Get mint card data (optimized for grid/list views) + */ +export function getMintCard(mintId) { + const mint = queryOne(` + SELECT + m.mint_id, + m.name, + m.canonical_url, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id = ? + `, [mintId]); + + if (!mint) return null; + + // Get uptime + const uptime = queryOne(` + SELECT uptime_pct FROM uptime_rollups + WHERE mint_id = ? AND window = '30d' + ORDER BY computed_at DESC LIMIT 1 + `, [mintId]); + + // Check for Tor URL + const hasTor = queryOne(` + SELECT 1 FROM mint_urls + WHERE mint_id = ? AND active = 1 AND (type = 'tor' OR url LIKE '%.onion%') + LIMIT 1 + `, [mintId]); + + // Get review count + const reviews = queryOne(` + SELECT COUNT(*) as count FROM reviews WHERE mint_id = ? + `, [mintId]); + + return { + mint_id: mint.mint_id, + name: mint.name, + canonical_url: mint.canonical_url, + icon_url: mint.icon_url, + status: mint.status, + trust_score: mint.trust_score, + trust_level: mint.trust_level, + uptime_30d: (uptime && uptime.uptime_pct) || null, + has_tor: !!hasTor, + review_count: (reviews && reviews.count) || 0 + }; +} \ No newline at end of file diff --git a/src/services/JobService.js b/src/services/JobService.js new file mode 100644 index 0000000..e0ec831 --- /dev/null +++ b/src/services/JobService.js @@ -0,0 +1,209 @@ +/** + * Job Service + * + * Database-backed job queue for background tasks. + * No external dependencies - uses SQLite for persistence. + */ + +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { nowISO } from '../utils/time.js'; + +/** + * Create a new job + */ +export function createJob(type, payload = {}, options = {}) { + const { priority = 0, runAt, maxRetries = 3 } = options; + const scheduledAt = runAt || nowISO(); + + const result = run(` + INSERT INTO jobs (type, payload, status, priority, run_at, max_retries, created_at) + VALUES (?, ?, 'pending', ?, ?, ?, ?) + `, [type, JSON.stringify(payload), priority, scheduledAt, maxRetries, nowISO()]); + + return result.lastInsertRowid; +} + +/** + * Get next job to process (atomic claim) + */ +export function claimJob(type = null) { + return transaction(() => { + let sql = ` + SELECT * FROM jobs + WHERE status = 'pending' AND run_at <= ? + `; + const params = [nowISO()]; + + if (type) { + sql += ' AND type = ?'; + params.push(type); + } + + sql += ' ORDER BY priority DESC, run_at ASC LIMIT 1'; + + const job = queryOne(sql, params); + if (!job) return null; + + // Claim the job + run(` + UPDATE jobs SET status = 'running', started_at = ? WHERE id = ? + `, [nowISO(), job.id]); + + return { + ...job, + payload: JSON.parse(job.payload || '{}') + }; + }); +} + +/** + * Mark job as completed + */ +export function completeJob(jobId) { + run(` + UPDATE jobs SET status = 'completed', completed_at = ? WHERE id = ? + `, [nowISO(), jobId]); +} + +/** + * Mark job as failed + */ +export function failJob(jobId, errorMessage) { + const job = queryOne('SELECT * FROM jobs WHERE id = ?', [jobId]); + if (!job) return; + + const newRetries = job.retries + 1; + + if (newRetries < job.max_retries) { + // Reschedule with exponential backoff + const backoffMs = Math.pow(2, newRetries) * 60000; // 2^n minutes + const nextRun = new Date(Date.now() + backoffMs).toISOString(); + + run(` + UPDATE jobs SET + status = 'pending', + retries = ?, + run_at = ?, + error_message = ? + WHERE id = ? + `, [newRetries, nextRun, errorMessage, jobId]); + } else { + // Max retries reached + run(` + UPDATE jobs SET + status = 'failed', + completed_at = ?, + error_message = ? + WHERE id = ? + `, [nowISO(), errorMessage, jobId]); + } +} + +/** + * Get job by ID + */ +export function getJob(jobId) { + const job = queryOne('SELECT * FROM jobs WHERE id = ?', [jobId]); + if (!job) return null; + + return { + ...job, + payload: JSON.parse(job.payload || '{}') + }; +} + +/** + * Get jobs by status + */ +export function getJobs(options = {}) { + const { status, type, limit = 100, offset = 0 } = options; + + let sql = 'SELECT * FROM jobs WHERE 1=1'; + const params = []; + + if (status) { + sql += ' AND status = ?'; + params.push(status); + } + + if (type) { + sql += ' AND type = ?'; + params.push(type); + } + + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return query(sql, params).map(job => ({ + ...job, + payload: JSON.parse(job.payload || '{}') + })); +} + +/** + * Get pending job count by type + */ +export function getPendingJobCounts() { + return query(` + SELECT type, COUNT(*) as count + FROM jobs + WHERE status = 'pending' + GROUP BY type + `); +} + +/** + * Delete old completed/failed jobs + */ +export function cleanupOldJobs(daysOld = 7) { + const threshold = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000).toISOString(); + + const result = run(` + DELETE FROM jobs + WHERE status IN ('completed', 'failed') + AND completed_at < ? + `, [threshold]); + + return result.changes; +} + +/** + * Reset stuck jobs (running for too long) + */ +export function resetStuckJobs(maxRunningMinutes = 30) { + const threshold = new Date(Date.now() - maxRunningMinutes * 60 * 1000).toISOString(); + + const result = run(` + UPDATE jobs + SET status = 'pending', started_at = NULL + WHERE status = 'running' + AND started_at < ? + `, [threshold]); + + return result.changes; +} + +/** + * Schedule recurring jobs + */ +export function scheduleRecurringJobs() { + const jobTypes = [ + { type: 'probe', interval: 5 * 60 * 1000 }, // Every 5 minutes + { type: 'metadata', interval: 60 * 60 * 1000 }, // Every hour + { type: 'rollup', interval: 60 * 60 * 1000 }, // Every hour + { type: 'trust_score', interval: 6 * 60 * 60 * 1000 }, // Every 6 hours + { type: 'cleanup', interval: 24 * 60 * 60 * 1000 } // Daily + ]; + + for (const { type, interval } of jobTypes) { + // Check if job already pending + const existing = queryOne(` + SELECT id FROM jobs WHERE type = ? AND status = 'pending' + `, [type]); + + if (!existing) { + createJob(type, { scheduled: true }); + } + } +} + diff --git a/src/services/MetadataService.js b/src/services/MetadataService.js new file mode 100644 index 0000000..cc85d81 --- /dev/null +++ b/src/services/MetadataService.js @@ -0,0 +1,358 @@ +/** + * Metadata Service + * + * Fetches and tracks mint metadata using NUT-06 (/v1/info endpoint). + * Maintains history of all metadata changes. + */ + +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { config } from '../config.js'; +import { buildEndpointUrl } from '../utils/url.js'; +import { hashMetadata } from '../utils/crypto.js'; +import { nowISO, hoursAgo } from '../utils/time.js'; +import { getMintById, updateMintStatus, addMintUrl } from './MintService.js'; + +/** + * Fetch metadata from mint + */ +export async function fetchMintMetadata(mintId) { + const mint = getMintById(mintId); + if (!mint) { + throw new Error(`Mint not found: ${mintId}`); + } + + // Only fetch if mint is online/degraded + if (!['online', 'degraded'].includes(mint.status)) { + return null; + } + + const infoUrl = buildEndpointUrl(mint.canonical_url, config.mintInfoEndpoint); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.probeTimeoutMs); + + const response = await fetch(infoUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Cashumints.space/1.0' + } + }); + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + return processMetadata(mintId, data); + } catch (error) { + console.error(`[Metadata] Failed to fetch for ${mintId}:`, error.message); + + // Record error in history + recordMetadataError(mintId, error.message); + return null; + } +} + +/** + * Process and store metadata + */ +function processMetadata(mintId, rawData) { + const now = nowISO(); + + // Parse and normalize metadata + const metadata = parseMetadata(rawData); + const contentHash = hashMetadata(metadata); + + // Get current snapshot + const currentSnapshot = getMetadataSnapshot(mintId); + + return transaction(() => { + // Check if metadata changed + const isNew = !currentSnapshot; + const hasChanged = currentSnapshot && currentSnapshot.content_hash !== contentHash; + + if (isNew || hasChanged) { + // Record history + const changeType = isNew ? 'initial' : 'update'; + const diff = hasChanged ? computeDiff(currentSnapshot, metadata) : null; + + run(` + INSERT INTO metadata_history (mint_id, fetched_at, change_type, diff, content_hash, version, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [mintId, now, changeType, JSON.stringify(diff), contentHash, metadata.version, JSON.stringify(rawData)]); + } + + // Upsert snapshot + run(` + INSERT INTO metadata_snapshots ( + mint_id, name, pubkey, version, description, description_long, + contact, motd, icon_url, urls, tos_url, nuts, server_time, + raw_json, content_hash, last_fetched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mint_id) DO UPDATE SET + name = excluded.name, + pubkey = excluded.pubkey, + version = excluded.version, + description = excluded.description, + description_long = excluded.description_long, + contact = excluded.contact, + motd = excluded.motd, + icon_url = excluded.icon_url, + urls = excluded.urls, + tos_url = excluded.tos_url, + nuts = excluded.nuts, + server_time = excluded.server_time, + raw_json = excluded.raw_json, + content_hash = excluded.content_hash, + last_fetched_at = excluded.last_fetched_at + `, [ + mintId, + metadata.name, + metadata.pubkey, + metadata.version, + metadata.description, + metadata.description_long, + JSON.stringify(metadata.contact), + metadata.motd, + metadata.icon_url, + JSON.stringify(metadata.urls), + metadata.tos_url, + JSON.stringify(metadata.nuts), + metadata.server_time, + JSON.stringify(rawData), + contentHash, + now + ]); + + // Update mint with name and icon + updateMintStatus(mintId, getMintById(mintId).status, { + name: metadata.name, + iconUrl: metadata.icon_url + }); + + // Discover new URLs from metadata + if (metadata.urls && Array.isArray(metadata.urls)) { + for (const url of metadata.urls) { + try { + addMintUrl(mintId, url); + } catch (e) { + // Ignore if URL exists elsewhere + } + } + } + + return getMetadataSnapshot(mintId); + }); +} + +/** + * Parse raw metadata to normalized structure + */ +function parseMetadata(rawData) { + // NUT-06 fields + return { + name: rawData.name || null, + pubkey: rawData.pubkey || null, + version: rawData.version || null, + description: rawData.description || null, + description_long: rawData.description_long || null, + contact: rawData.contact || null, + motd: rawData.motd || null, + icon_url: rawData.icon_url || rawData.icon || null, + urls: rawData.urls || null, + tos_url: rawData.tos_url || null, + nuts: rawData.nuts || null, + server_time: rawData.time ? new Date(rawData.time * 1000).toISOString() : null, + }; +} + +/** + * Compute diff between old and new metadata + */ +function computeDiff(oldData, newData) { + const diff = {}; + const keys = new Set([...Object.keys(oldData), ...Object.keys(newData)]); + + for (const key of keys) { + // Skip non-comparable fields + if (['content_hash', 'last_fetched_at', 'raw_json', 'mint_id', 'server_time'].includes(key)) continue; + + const oldVal = JSON.stringify(oldData[key]); + const newVal = JSON.stringify(newData[key]); + + if (oldVal !== newVal) { + diff[key] = { + old: oldData[key], + new: newData[key] + }; + } + } + + return Object.keys(diff).length > 0 ? diff : null; +} + +/** + * Record metadata fetch error + */ +function recordMetadataError(mintId, errorMessage) { + const now = nowISO(); + + run(` + INSERT INTO metadata_history (mint_id, fetched_at, change_type, diff) + VALUES (?, ?, 'error', ?) + `, [mintId, now, JSON.stringify({ error: errorMessage })]); +} + +/** + * Get current metadata snapshot + */ +export function getMetadataSnapshot(mintId) { + const snapshot = queryOne(` + SELECT * FROM metadata_snapshots WHERE mint_id = ? + `, [mintId]); + + if (!snapshot) return null; + + // Parse JSON fields + return { + ...snapshot, + contact: snapshot.contact ? JSON.parse(snapshot.contact) : null, + urls: snapshot.urls ? JSON.parse(snapshot.urls) : null, + nuts: snapshot.nuts ? JSON.parse(snapshot.nuts) : null, + }; +} + +/** + * Get metadata history + */ +export function getMetadataHistory(mintId, options = {}) { + const { limit = 50, since } = options; + + let sql = ` + SELECT id, mint_id, fetched_at, change_type, diff, version + FROM metadata_history + WHERE mint_id = ? + `; + const params = [mintId]; + + if (since) { + sql += ' AND fetched_at >= ?'; + params.push(since); + } + + sql += ' ORDER BY fetched_at DESC LIMIT ?'; + params.push(limit); + + const history = query(sql, params); + + // Parse JSON diffs + return history.map(h => ({ + ...h, + diff: h.diff ? JSON.parse(h.diff) : null + })); +} + +/** + * Get mints that need metadata refresh + */ +export function getMintsNeedingMetadata() { + const threshold = hoursAgo(1); + + return query(` + SELECT m.mint_id, m.canonical_url, m.status + FROM mints m + LEFT JOIN metadata_snapshots ms ON m.mint_id = ms.mint_id + WHERE + m.status IN ('online', 'degraded') + AND (ms.last_fetched_at IS NULL OR ms.last_fetched_at < ?) + ORDER BY ms.last_fetched_at ASC NULLS FIRST + `, [threshold]); +} + +/** + * Extract supported NUTs from metadata + */ +export function getSupportedNuts(mintId) { + const snapshot = getMetadataSnapshot(mintId); + if (!snapshot || !snapshot.nuts) return []; + + const supported = []; + for (const [nut, config] of Object.entries(snapshot.nuts)) { + if (config && (config.supported === true || config.disabled !== true)) { + supported.push({ + nut: parseInt(nut), + config + }); + } + } + + return supported.sort((a, b) => a.nut - b.nut); +} + +/** + * Derive feature flags from metadata + */ +export function deriveFeatures(mintId) { + const snapshot = getMetadataSnapshot(mintId); + const mint = getMintById(mintId); + + if (!snapshot) { + return { + supported_nuts: [], + supports_bolt11: false, + min_amount: null, + max_amount: null, + has_tor_endpoint: false, + has_multiple_urls: false, + feature_completeness_score: 0 + }; + } + + const nuts = snapshot.nuts || {}; + const supportedNuts = Object.keys(nuts).map(n => parseInt(n)).filter(n => !isNaN(n)); + + // Check for Bolt11 support (NUT-04 and NUT-05) + const supportsBolt11 = supportedNuts.includes(4) || supportedNuts.includes(5); + + // Get mint limits from NUT-04 or NUT-05 + let minAmount = null; + let maxAmount = null; + + if (nuts['4'] && nuts['4'].methods) { + for (const method of nuts['4'].methods) { + if (method.min_amount !== undefined) minAmount = method.min_amount; + if (method.max_amount !== undefined) maxAmount = method.max_amount; + } + } + + // Check for Tor endpoint + const urls = snapshot.urls || []; + const hasTor = urls.some(u => u.includes('.onion')); + + // Feature completeness score (0-100) + let completenessScore = 0; + if (snapshot.name) completenessScore += 10; + if (snapshot.description) completenessScore += 10; + if (snapshot.pubkey) completenessScore += 15; + if (snapshot.contact) completenessScore += 10; + if (snapshot.icon_url) completenessScore += 5; + if (supportsBolt11) completenessScore += 20; + if (supportedNuts.includes(7)) completenessScore += 10; // Token state check + if (supportedNuts.includes(10)) completenessScore += 10; // Spending conditions + if (hasTor) completenessScore += 10; + + return { + supported_nuts: supportedNuts, + supports_bolt11: supportsBolt11, + min_amount: minAmount, + max_amount: maxAmount, + has_tor_endpoint: hasTor, + has_multiple_urls: urls.length > 1, + feature_completeness_score: Math.min(100, completenessScore) + }; +} \ No newline at end of file diff --git a/src/services/MintService.js b/src/services/MintService.js new file mode 100644 index 0000000..c344802 --- /dev/null +++ b/src/services/MintService.js @@ -0,0 +1,284 @@ +/** + * Mint Service + * + * Core operations for mint identity management. + * Handles mint creation, URL resolution, and status tracking. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { normalizeUrl, getUrlType, isValidUrl } from '../utils/url.js'; +import { nowISO } from '../utils/time.js'; + +/** + * Create a new mint from URL + */ +export function createMint(url, options = {}) { + if (!isValidUrl(url)) { + throw new Error('Invalid mint URL'); + } + + const normalizedUrl = normalizeUrl(url); + const urlType = getUrlType(url); + const mintId = uuidv4(); + const now = nowISO(); + + return transaction(() => { + // Create mint + run(` + INSERT INTO mints (mint_id, canonical_url, name, status, created_at, updated_at) + VALUES (?, ?, ?, 'unknown', ?, ?) + `, [mintId, normalizedUrl, options.name || null, now, now]); + + // Create URL entry + run(` + INSERT INTO mint_urls (mint_id, url, url_normalized, type, active, discovered_at) + VALUES (?, ?, ?, ?, 1, ?) + `, [mintId, url, normalizedUrl, urlType, now]); + + return getMintById(mintId); + }); +} + +/** + * Get mint by ID + */ +export function getMintById(mintId) { + return queryOne(` + SELECT + m.*, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id = ? + `, [mintId]); +} + +/** + * Get mint by URL (resolves to canonical mint) + */ +export function getMintByUrl(url) { + const normalizedUrl = normalizeUrl(url); + if (!normalizedUrl) return null; + + const mintUrl = queryOne(` + SELECT mint_id FROM mint_urls + WHERE url_normalized = ? AND active = 1 + `, [normalizedUrl]); + + if (!mintUrl) return null; + return getMintById(mintUrl.mint_id); +} + +/** + * Resolve mint from ID or URL + */ +export function resolveMint(identifier) { + // Try as mint_id first (UUID format) + if (identifier && identifier.includes('-') && identifier.length === 36) { + const mint = getMintById(identifier); + if (mint) return mint; + } + + // Try as URL + return getMintByUrl(identifier); +} + +/** + * Get all URLs for a mint + */ +export function getMintUrls(mintId) { + return query(` + SELECT url, type, active, discovered_at, last_seen_at + FROM mint_urls + WHERE mint_id = ? + ORDER BY type, active DESC, discovered_at ASC + `, [mintId]); +} + +/** + * Add URL to existing mint + */ +export function addMintUrl(mintId, url) { + const normalizedUrl = normalizeUrl(url); + const urlType = getUrlType(url); + const now = nowISO(); + + // Check if URL already exists + const existing = queryOne(` + SELECT id, mint_id, active FROM mint_urls WHERE url_normalized = ? + `, [normalizedUrl]); + + if (existing) { + if (existing.mint_id === mintId) { + // Reactivate if inactive + if (!existing.active) { + run(`UPDATE mint_urls SET active = 1, last_seen_at = ? WHERE id = ?`, [now, existing.id]); + } + return existing; + } + throw new Error('URL already associated with another mint'); + } + + // Add new URL + run(` + INSERT INTO mint_urls (mint_id, url, url_normalized, type, active, discovered_at, last_seen_at) + VALUES (?, ?, ?, ?, 1, ?, ?) + `, [mintId, url, normalizedUrl, urlType, now, now]); + + return { mint_id: mintId, url, url_normalized: normalizedUrl, type: urlType }; +} + +/** + * Deactivate URL (never delete) + */ +export function deactivateUrl(mintId, url) { + const normalizedUrl = normalizeUrl(url); + run(` + UPDATE mint_urls + SET active = 0 + WHERE mint_id = ? AND url_normalized = ? + `, [mintId, normalizedUrl]); +} + +/** + * Update mint status + */ +export function updateMintStatus(mintId, status, options = {}) { + const now = nowISO(); + const updates = ['status = ?', 'updated_at = ?']; + const params = [status, now]; + + if (options.offlineSince !== undefined) { + updates.push('offline_since = ?'); + params.push(options.offlineSince); + } + + if (options.lastSuccessAt !== undefined) { + updates.push('last_success_at = ?'); + params.push(options.lastSuccessAt); + } + + if (options.lastFailureAt !== undefined) { + updates.push('last_failure_at = ?'); + params.push(options.lastFailureAt); + } + + if (options.consecutiveFailures !== undefined) { + updates.push('consecutive_failures = ?'); + params.push(options.consecutiveFailures); + } + + if (options.name !== undefined) { + updates.push('name = ?'); + params.push(options.name); + } + + if (options.iconUrl !== undefined) { + updates.push('icon_url = ?'); + params.push(options.iconUrl); + } + + params.push(mintId); + + run(` + UPDATE mints SET ${updates.join(', ')} WHERE mint_id = ? + `, params); + + return getMintById(mintId); +} + +/** + * Get all mints with filters + */ +export function getMints(options = {}) { + const { status, limit = 100, offset = 0, sortBy = 'created_at', sortOrder = 'DESC', includeHidden = false } = options; + + let sql = ` + SELECT + m.*, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE 1=1 + `; + + const params = []; + + // Filter hidden mints by default (unless admin) + if (!includeHidden) { + sql += ` AND (m.visibility IS NULL OR m.visibility = 'public')`; + } + + // Exclude merged mints + sql += ` AND (m.status IS NULL OR m.status != 'merged')`; + + if (status) { + sql += ' AND m.status = ?'; + params.push(status); + } + + // Validate sort column + const validSortColumns = ['created_at', 'updated_at', 'name', 'trust_score']; + const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at'; + const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + + sql += ` ORDER BY ${sortColumn === 'trust_score' ? 'ts.score_total' : 'm.' + sortColumn} ${order}`; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return query(sql, params); +} + +/** + * Count mints by status + */ +export function countMintsByStatus() { + return query(` + SELECT status, COUNT(*) as count + FROM mints + GROUP BY status + `); +} + +/** + * Get or create mint from URL + */ +export function getOrCreateMint(url) { + const existing = getMintByUrl(url); + if (existing) return existing; + return createMint(url); +} + +/** + * Submit new mint (from user submission) + */ +export function submitMint(url) { + if (!isValidUrl(url)) { + return { success: false, message: 'Invalid URL format' }; + } + + const normalizedUrl = normalizeUrl(url); + + // Check if already exists + const existing = getMintByUrl(url); + if (existing) { + return { + success: true, + mint_id: existing.mint_id, + status: existing.status, + message: 'Mint already tracked' + }; + } + + // Create new mint + const mint = createMint(url); + return { + success: true, + mint_id: mint.mint_id, + status: 'unknown', + message: 'Mint submitted for tracking' + }; +} \ No newline at end of file diff --git a/src/services/PageviewService.js b/src/services/PageviewService.js new file mode 100644 index 0000000..30602a3 --- /dev/null +++ b/src/services/PageviewService.js @@ -0,0 +1,325 @@ +/** + * Pageview Service + * + * Tracks mint page views for popularity metrics. + * Uses Plausible Analytics for tracking and stats. + * Falls back to local DB when Plausible is not configured. + */ + +import { query, queryOne, run } from '../db/connection.js'; +import { nowISO, daysAgo, hoursAgo } from '../utils/time.js'; +import { v4 as uuidv4 } from 'uuid'; +import { + isPlausibleConfigured, + trackPageview as plausibleTrackPageview, + getMintPageviewStats as plausibleGetMintStats, + getTopMints as plausibleGetTopMints, + getMintTimeseries as plausibleGetMintTimeseries, + getCustomPropBreakdown, +} from './PlausibleService.js'; + +/** + * Record a pageview + * Sends to Plausible if configured, also stores locally for fallback + */ +export async function recordPageview(mintId, options = {}) { + const { sessionId, userAgent, referer, ip, mintUrl } = options; + + // Generate session ID if not provided + const session = sessionId || uuidv4(); + + // Track in Plausible (async, don't block) + if (isPlausibleConfigured()) { + plausibleTrackPageview(mintId, mintUrl || '', { + userAgent, + referer, + ip, + }).catch(err => { + console.error('[Pageview] Plausible tracking error:', err.message); + }); + } + + // Also store locally for fallback/backup + run(` + INSERT INTO pageviews (mint_id, session_id, viewed_at, user_agent, referer) + VALUES (?, ?, ?, ?, ?) + `, [mintId, session, nowISO(), userAgent || null, referer || null]); + + return { session_id: session }; +} + +/** + * Get pageview stats for a mint + * Fetches from Plausible if configured, falls back to local DB + */ +export async function getPageviewStats(mintId) { + // Try Plausible first + if (isPlausibleConfigured()) { + try { + const [stats24h, stats7d, stats30d] = await Promise.all([ + plausibleGetMintStats(mintId, 'day'), + plausibleGetMintStats(mintId, '7d'), + plausibleGetMintStats(mintId, '30d'), + ]); + + if (stats30d) { + return { + views_24h: stats24h?.pageviews?.value || 0, + views_7d: stats7d?.pageviews?.value || 0, + views_30d: stats30d?.pageviews?.value || 0, + unique_sessions_30d: stats30d?.visitors?.value || 0, + view_velocity: stats7d?.pageviews?.value ? Math.round((stats7d.pageviews.value / 7) * 10) / 10 : 0, + source: 'plausible', + }; + } + } catch (error) { + console.error('[Pageview] Plausible stats error, falling back to local:', error.message); + } + } + + // Fallback to local DB + return getLocalPageviewStats(mintId); +} + +/** + * Get pageview stats from local database (fallback) + */ +function getLocalPageviewStats(mintId) { + // Views in last 24h + const views24hResult = queryOne(` + SELECT COUNT(*) as count FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, hoursAgo(24)]); + const views24h = views24hResult ? views24hResult.count : 0; + + // Views in last 7d + const views7dResult = queryOne(` + SELECT COUNT(*) as count FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, daysAgo(7)]); + const views7d = views7dResult ? views7dResult.count : 0; + + // Views in last 30d + const views30dResult = queryOne(` + SELECT COUNT(*) as count FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, daysAgo(30)]); + const views30d = views30dResult ? views30dResult.count : 0; + + // Unique sessions in last 30d + const uniqueSessionsResult = queryOne(` + SELECT COUNT(DISTINCT session_id) as count FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, daysAgo(30)]); + const uniqueSessions = uniqueSessionsResult ? uniqueSessionsResult.count : 0; + + // View velocity (views per day over last 7 days) + const viewVelocity = Math.round((views7d / 7) * 10) / 10; + + return { + views_24h: views24h, + views_7d: views7d, + views_30d: views30d, + unique_sessions_30d: uniqueSessions, + view_velocity: viewVelocity, + source: 'local', + }; +} + +/** + * Calculate pageview rollups (for local backup data) + */ +export function calculatePageviewRollup(mintId, window) { + const periodStart = window === '24h' ? hoursAgo(24) : + window === '7d' ? daysAgo(7) : + daysAgo(30); + const periodEnd = nowISO(); + + const stats = queryOne(` + SELECT + COUNT(*) as view_count, + COUNT(DISTINCT session_id) as unique_sessions + FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + `, [mintId, periodStart]); + + // Upsert rollup + run(` + INSERT INTO pageview_rollups (mint_id, window, period_start, period_end, view_count, unique_sessions, computed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mint_id, window, period_start) DO UPDATE SET + period_end = excluded.period_end, + view_count = excluded.view_count, + unique_sessions = excluded.unique_sessions, + computed_at = excluded.computed_at + `, [mintId, window, periodStart, periodEnd, stats ? stats.view_count : 0, stats ? stats.unique_sessions : 0, nowISO()]); + + return stats; +} + +/** + * Get trending mints by view velocity + * Uses Plausible if configured, falls back to local DB + */ +export async function getTrendingMints(limit = 10, window = '7d') { + const period = window === '24h' ? 'day' : '7d'; + const days = window === '24h' ? 1 : 7; + + // Try Plausible first + if (isPlausibleConfigured()) { + try { + const topMints = await plausibleGetTopMints(period, limit * 2); // Get extra to filter + + if (topMints && topMints.length > 0) { + // Get mint details from local DB + const mintIds = topMints.map(m => m.mint_id); + const placeholders = mintIds.map(() => '?').join(','); + + const mints = query(` + SELECT + m.mint_id, + m.canonical_url, + m.name, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.mint_id IN (${placeholders}) + AND m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + `, mintIds); + + // Merge Plausible data with mint details + const mintMap = new Map(mints.map(m => [m.mint_id, m])); + + return topMints + .filter(pm => mintMap.has(pm.mint_id)) + .slice(0, limit) + .map(pm => { + const mint = mintMap.get(pm.mint_id); + return { + ...mint, + view_count: pm.pageviews, + unique_sessions: pm.visitors, + view_velocity: Math.round((pm.pageviews / days) * 10) / 10, + source: 'plausible', + }; + }); + } + } catch (error) { + console.error('[Pageview] Plausible trending error, falling back to local:', error.message); + } + } + + // Fallback to local DB + return getLocalTrendingMints(limit, window); +} + +/** + * Get trending mints from local database (fallback) + */ +function getLocalTrendingMints(limit = 10, window = '7d') { + const since = window === '24h' ? hoursAgo(24) : daysAgo(7); + const days = window === '24h' ? 1 : 7; + + const mints = query(` + SELECT + m.mint_id, + m.canonical_url, + m.name, + m.icon_url, + m.status, + ts.score_total as trust_score, + ts.score_level as trust_level, + COUNT(p.id) as view_count, + COUNT(DISTINCT p.session_id) as unique_sessions + FROM mints m + LEFT JOIN pageviews p ON m.mint_id = p.mint_id AND p.viewed_at >= ? + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.status IN ('online', 'degraded') + AND (m.visibility IS NULL OR m.visibility = 'public') + GROUP BY m.mint_id + HAVING view_count > 0 + ORDER BY view_count DESC + LIMIT ? + `, [since, limit]); + + // Calculate view velocity + return mints.map(m => ({ + ...m, + view_velocity: Math.round((m.view_count / days) * 10) / 10, + source: 'local', + })); +} + +/** + * Cleanup old pageviews (older than 90 days) + * Still needed for local backup data + */ +export function cleanupOldPageviews() { + const ninetyDaysAgo = daysAgo(90); + + const result = run(` + DELETE FROM pageviews WHERE viewed_at < ? + `, [ninetyDaysAgo]); + + return result.changes; +} + +/** + * Get pageview timeseries for a mint + * Uses Plausible if configured, falls back to local DB + */ +export async function getViewsTimeseries(mintId, window = '7d', bucket = '1d') { + // Try Plausible first + if (isPlausibleConfigured()) { + try { + const period = window === '30d' ? '30d' : '7d'; + const timeseries = await plausibleGetMintTimeseries(mintId, period); + + if (timeseries && timeseries.length > 0) { + return timeseries.map(row => ({ + timestamp: row.date, + views: row.pageviews || 0, + unique_sessions: row.visitors || 0, + source: 'plausible', + })); + } + } catch (error) { + console.error('[Pageview] Plausible timeseries error, falling back to local:', error.message); + } + } + + // Fallback to local DB + return getLocalViewsTimeseries(mintId, window, bucket); +} + +/** + * Get pageview timeseries from local database (fallback) + */ +function getLocalViewsTimeseries(mintId, window = '7d', bucket = '1d') { + const since = window === '30d' ? daysAgo(30) : daysAgo(7); + + // Determine bucket format + const bucketFormat = bucket === '1h' ? '%Y-%m-%d %H:00:00' : '%Y-%m-%d'; + + const data = query(` + SELECT + strftime(?, viewed_at) as bucket_time, + COUNT(*) as views, + COUNT(DISTINCT session_id) as unique_sessions + FROM pageviews + WHERE mint_id = ? AND viewed_at >= ? + GROUP BY bucket_time + ORDER BY bucket_time ASC + `, [bucketFormat, mintId, since]); + + return data.map(row => ({ + timestamp: row.bucket_time, + views: row.views, + unique_sessions: row.unique_sessions, + source: 'local', + })); +} diff --git a/src/services/PlausibleService.js b/src/services/PlausibleService.js new file mode 100644 index 0000000..c036e3e --- /dev/null +++ b/src/services/PlausibleService.js @@ -0,0 +1,413 @@ +/** + * Plausible Analytics Service + * + * Integrates with self-hosted Plausible Analytics for: + * - Tracking pageviews and mint views + * - Fetching popularity and trending data + * - Aggregate analytics stats + */ + +import { config } from '../config.js'; + +const PLAUSIBLE_URL = config.plausible.url.replace(/\/$/, ''); // Remove trailing slash +const API_KEY = config.plausible.apiKey; +const SITE_ID = config.plausible.siteId; + +/** + * Check if Plausible is configured + */ +export function isPlausibleConfigured() { + return !!(PLAUSIBLE_URL && API_KEY && SITE_ID); +} + +/** + * Track a pageview event + * Uses Plausible Events API + * + * @param {string} mintId - The mint ID being viewed + * @param {string} mintUrl - The canonical URL of the mint + * @param {object} options - Additional tracking options + */ +export async function trackPageview(mintId, mintUrl, options = {}) { + if (!PLAUSIBLE_URL) { + console.warn('[Plausible] URL not configured, skipping pageview tracking'); + return { success: false, reason: 'not_configured' }; + } + + const { userAgent, referer, ip } = options; + + try { + const response = await fetch(`${PLAUSIBLE_URL}/api/event`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent || 'CashumintsAPI/1.0', + ...(ip && { 'X-Forwarded-For': ip }), + }, + body: JSON.stringify({ + domain: SITE_ID, + name: 'pageview', + url: `https://${SITE_ID}/mint/${mintId}`, + referrer: referer || '', + props: { + mint_id: mintId, + mint_url: mintUrl, + }, + }), + }); + + if (!response.ok) { + console.error('[Plausible] Failed to track pageview:', response.status, await response.text()); + return { success: false, reason: 'api_error', status: response.status }; + } + + return { success: true }; + } catch (error) { + console.error('[Plausible] Error tracking pageview:', error.message); + return { success: false, reason: 'network_error', error: error.message }; + } +} + +/** + * Track a custom event + * + * @param {string} eventName - Event name (e.g., 'mint_submit', 'review_view') + * @param {object} props - Custom properties + * @param {object} options - Request options + */ +export async function trackEvent(eventName, props = {}, options = {}) { + if (!PLAUSIBLE_URL) { + return { success: false, reason: 'not_configured' }; + } + + const { userAgent, referer, ip, url } = options; + + try { + const response = await fetch(`${PLAUSIBLE_URL}/api/event`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent || 'CashumintsAPI/1.0', + ...(ip && { 'X-Forwarded-For': ip }), + }, + body: JSON.stringify({ + domain: SITE_ID, + name: eventName, + url: url || `https://${SITE_ID}`, + referrer: referer || '', + props, + }), + }); + + if (!response.ok) { + console.error('[Plausible] Failed to track event:', response.status); + return { success: false, reason: 'api_error', status: response.status }; + } + + return { success: true }; + } catch (error) { + console.error('[Plausible] Error tracking event:', error.message); + return { success: false, reason: 'network_error', error: error.message }; + } +} + +/** + * Fetch aggregate stats from Plausible Stats API + * + * @param {string} period - Time period (e.g., 'day', '7d', '30d', 'month') + * @param {object} options - Query options + */ +export async function getAggregateStats(period = '30d', options = {}) { + if (!isPlausibleConfigured()) { + return null; + } + + const { metrics = 'visitors,pageviews,visits', filters } = options; + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + metrics, + }); + + if (filters) { + params.append('filters', filters); + } + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/aggregate?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch aggregate stats:', response.status); + return null; + } + + const data = await response.json(); + return data.results; + } catch (error) { + console.error('[Plausible] Error fetching aggregate stats:', error.message); + return null; + } +} + +/** + * Get pageview stats for a specific mint + * + * @param {string} mintId - The mint ID + * @param {string} period - Time period + */ +export async function getMintPageviewStats(mintId, period = '30d') { + if (!isPlausibleConfigured()) { + return null; + } + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + metrics: 'visitors,pageviews,visits', + filters: `event:page==/mint/${mintId}`, + }); + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/aggregate?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch mint stats:', response.status); + return null; + } + + const data = await response.json(); + return data.results; + } catch (error) { + console.error('[Plausible] Error fetching mint stats:', error.message); + return null; + } +} + +/** + * Get breakdown stats (e.g., top mints by pageviews) + * + * @param {string} property - Property to break down by + * @param {string} period - Time period + * @param {object} options - Query options + */ +export async function getBreakdown(property, period = '30d', options = {}) { + if (!isPlausibleConfigured()) { + return null; + } + + const { metrics = 'visitors,pageviews', limit = 100, filters } = options; + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + property, + metrics, + limit: String(limit), + }); + + if (filters) { + params.append('filters', filters); + } + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/breakdown?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch breakdown:', response.status); + return null; + } + + const data = await response.json(); + return data.results; + } catch (error) { + console.error('[Plausible] Error fetching breakdown:', error.message); + return null; + } +} + +/** + * Get top pages (mints) by pageviews + * + * @param {string} period - Time period ('day', '7d', '30d') + * @param {number} limit - Max results + */ +export async function getTopMints(period = '7d', limit = 20) { + if (!isPlausibleConfigured()) { + return []; + } + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + property: 'event:page', + metrics: 'visitors,pageviews', + limit: String(limit), + filters: 'event:page==/mint/*', + }); + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/breakdown?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch top mints:', response.status); + return []; + } + + const data = await response.json(); + + // Parse mint IDs from page paths + return (data.results || []).map(item => { + const mintId = item.page.replace('/mint/', ''); + return { + mint_id: mintId, + visitors: item.visitors, + pageviews: item.pageviews, + }; + }); + } catch (error) { + console.error('[Plausible] Error fetching top mints:', error.message); + return []; + } +} + +/** + * Get custom property breakdown (e.g., by mint_id prop) + * + * @param {string} propName - Custom property name + * @param {string} period - Time period + * @param {number} limit - Max results + */ +export async function getCustomPropBreakdown(propName, period = '7d', limit = 100) { + if (!isPlausibleConfigured()) { + return []; + } + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + property: `event:props:${propName}`, + metrics: 'visitors,pageviews,events', + limit: String(limit), + }); + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/breakdown?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch prop breakdown:', response.status); + return []; + } + + const data = await response.json(); + return data.results || []; + } catch (error) { + console.error('[Plausible] Error fetching prop breakdown:', error.message); + return []; + } +} + +/** + * Get timeseries data + * + * @param {string} period - Time period + * @param {object} options - Query options + */ +export async function getTimeseries(period = '30d', options = {}) { + if (!isPlausibleConfigured()) { + return []; + } + + const { metrics = 'visitors,pageviews', filters, interval = 'date' } = options; + + try { + const params = new URLSearchParams({ + site_id: SITE_ID, + period, + metrics, + interval, + }); + + if (filters) { + params.append('filters', filters); + } + + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/timeseries?${params}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch timeseries:', response.status); + return []; + } + + const data = await response.json(); + return data.results || []; + } catch (error) { + console.error('[Plausible] Error fetching timeseries:', error.message); + return []; + } +} + +/** + * Get timeseries for a specific mint + * + * @param {string} mintId - Mint ID + * @param {string} period - Time period + */ +export async function getMintTimeseries(mintId, period = '30d') { + return getTimeseries(period, { + filters: `event:page==/mint/${mintId}`, + metrics: 'visitors,pageviews', + }); +} + +/** + * Get realtime visitor count + */ +export async function getRealtimeVisitors() { + if (!isPlausibleConfigured()) { + return null; + } + + try { + const response = await fetch(`${PLAUSIBLE_URL}/api/v1/stats/realtime/visitors?site_id=${SITE_ID}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + }, + }); + + if (!response.ok) { + console.error('[Plausible] Failed to fetch realtime visitors:', response.status); + return null; + } + + return await response.json(); + } catch (error) { + console.error('[Plausible] Error fetching realtime visitors:', error.message); + return null; + } +} + diff --git a/src/services/ProbeService.js b/src/services/ProbeService.js new file mode 100644 index 0000000..18ab92c --- /dev/null +++ b/src/services/ProbeService.js @@ -0,0 +1,297 @@ +/** + * Probe Service + * + * HTTP probing for mint availability and response times. + * Records all probe results for historical analysis. + */ + +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { config } from '../config.js'; +import { normalizeUrl, buildEndpointUrl } from '../utils/url.js'; +import { nowISO, daysAgo } from '../utils/time.js'; +import { updateMintStatus, getMintById, getMintUrls } from './MintService.js'; + +/** + * Probe a mint endpoint + */ +export async function probeMint(mintId, url = null) { + const mint = getMintById(mintId); + if (!mint) { + throw new Error(`Mint not found: ${mintId}`); + } + + // Use canonical URL if not specified + const probeUrl = url || mint.canonical_url; + const infoUrl = buildEndpointUrl(probeUrl, config.mintInfoEndpoint); + + const startTime = Date.now(); + let success = false; + let statusCode = null; + let rttMs = null; + let errorType = null; + let errorMessage = null; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.probeTimeoutMs); + + const response = await fetch(infoUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Cashumints.space/1.0' + } + }); + + clearTimeout(timeout); + + rttMs = Date.now() - startTime; + statusCode = response.status; + success = response.ok; + + if (!success) { + errorType = 'http_error'; + errorMessage = `HTTP ${statusCode}`; + } + } catch (error) { + rttMs = Date.now() - startTime; + + if (error.name === 'AbortError') { + errorType = 'timeout'; + errorMessage = `Timeout after ${config.probeTimeoutMs}ms`; + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + errorType = 'connection_error'; + errorMessage = error.message; + } else { + errorType = 'network_error'; + errorMessage = error.message; + } + } + + // Record probe result + const probeResult = recordProbe(mintId, probeUrl, success, statusCode, rttMs, errorType, errorMessage); + + // Update mint status based on probe result + await updateMintFromProbe(mintId, probeResult); + + return probeResult; +} + +/** + * Record probe result in database + */ +function recordProbe(mintId, url, success, statusCode, rttMs, errorType, errorMessage) { + const now = nowISO(); + + run(` + INSERT INTO probes (mint_id, url, probed_at, success, status_code, rtt_ms, error_type, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, [mintId, url, now, success ? 1 : 0, statusCode, rttMs, errorType, errorMessage]); + + return { + mint_id: mintId, + url, + probed_at: now, + success, + status_code: statusCode, + rtt_ms: rttMs, + error_type: errorType, + error_message: errorMessage + }; +} + +/** + * Update mint status based on probe result + */ +async function updateMintFromProbe(mintId, probeResult) { + const mint = getMintById(mintId); + const now = nowISO(); + + if (probeResult.success) { + // Determine if degraded (slow response) + const status = probeResult.rtt_ms > config.degradedRttMs ? 'degraded' : 'online'; + + updateMintStatus(mintId, status, { + lastSuccessAt: now, + consecutiveFailures: 0, + offlineSince: null + }); + + // Resolve any open incident + resolveOpenIncident(mintId, now); + } else { + const consecutiveFailures = (mint.consecutive_failures || 0) + 1; + + if (consecutiveFailures >= config.consecutiveFailuresOffline) { + // Determine if abandoned + const abandonedThreshold = daysAgo(config.abandonedAfterDays); + const lastSuccess = mint.last_success_at || mint.created_at; + const status = lastSuccess < abandonedThreshold ? 'abandoned' : 'offline'; + + updateMintStatus(mintId, status, { + lastFailureAt: now, + consecutiveFailures, + offlineSince: mint.offline_since || now + }); + + // Create incident if transitioning to offline + if (mint.status === 'online' || mint.status === 'degraded') { + createIncident(mintId, now); + } + } else { + // Keep current status, increment failures + updateMintStatus(mintId, mint.status, { + lastFailureAt: now, + consecutiveFailures + }); + } + } +} + +/** + * Create downtime incident + */ +function createIncident(mintId, startedAt) { + // Check for existing open incident + const existingIncident = queryOne(` + SELECT id FROM incidents + WHERE mint_id = ? AND resolved_at IS NULL + `, [mintId]); + + if (existingIncident) return; + + run(` + INSERT INTO incidents (mint_id, started_at, severity) + VALUES (?, ?, 'minor') + `, [mintId, startedAt]); +} + +/** + * Resolve open incident + */ +function resolveOpenIncident(mintId, resolvedAt) { + const incident = queryOne(` + SELECT id, started_at FROM incidents + WHERE mint_id = ? AND resolved_at IS NULL + `, [mintId]); + + if (!incident) return; + + const durationSeconds = Math.floor( + (new Date(resolvedAt) - new Date(incident.started_at)) / 1000 + ); + + // Determine severity based on duration + let severity = 'minor'; + if (durationSeconds > 3600) severity = 'major'; // > 1 hour + if (durationSeconds > 86400) severity = 'critical'; // > 24 hours + + run(` + UPDATE incidents + SET resolved_at = ?, duration_seconds = ?, severity = ? + WHERE id = ? + `, [resolvedAt, durationSeconds, severity, incident.id]); +} + +/** + * Get recent probes for a mint + */ +export function getProbes(mintId, limit = 100) { + return query(` + SELECT * FROM probes + WHERE mint_id = ? + ORDER BY probed_at DESC + LIMIT ? + `, [mintId, limit]); +} + +/** + * Get probe stats for time window + */ +export function getProbeStats(mintId, since) { + const stats = queryOne(` + SELECT + COUNT(*) as total_checks, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as ok_checks, + AVG(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as avg_rtt_ms, + MIN(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as min_rtt_ms, + MAX(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as max_rtt_ms + FROM probes + WHERE mint_id = ? AND probed_at >= ? + `, [mintId, since]); + + return stats; +} + +/** + * Get the latest probe for a mint + */ +export function getLatestProbe(mintId) { + return queryOne(` + SELECT * FROM probes + WHERE mint_id = ? + ORDER BY probed_at DESC + LIMIT 1 + `, [mintId]); +} + +/** + * Get mints that need probing + */ +export function getMintsToProbe() { + const now = Date.now(); + const onlineThreshold = new Date(now - config.probeIntervalOnlineMs).toISOString(); + const offlineThreshold = new Date(now - config.probeIntervalOfflineMs).toISOString(); + + return query(` + SELECT m.mint_id, m.canonical_url, m.status, m.last_success_at + FROM mints m + LEFT JOIN ( + SELECT mint_id, MAX(probed_at) as last_probed_at + FROM probes + GROUP BY mint_id + ) p ON m.mint_id = p.mint_id + WHERE + (m.status IN ('online', 'degraded') AND (p.last_probed_at IS NULL OR p.last_probed_at < ?)) + OR + (m.status IN ('unknown', 'offline', 'abandoned') AND (p.last_probed_at IS NULL OR p.last_probed_at < ?)) + ORDER BY p.last_probed_at ASC NULLS FIRST + `, [onlineThreshold, offlineThreshold]); +} + +/** + * Get incidents for a mint + */ +export function getIncidents(mintId, options = {}) { + const { limit = 50, since } = options; + + let sql = ` + SELECT * FROM incidents + WHERE mint_id = ? + `; + const params = [mintId]; + + if (since) { + sql += ' AND started_at >= ?'; + params.push(since); + } + + sql += ' ORDER BY started_at DESC LIMIT ?'; + params.push(limit); + + return query(sql, params); +} + +/** + * Count incidents in time window + */ +export function countIncidents(mintId, since) { + const result = queryOne(` + SELECT COUNT(*) as count + FROM incidents + WHERE mint_id = ? AND started_at >= ? + `, [mintId, since]); + + return result ? result.count : 0; +} \ No newline at end of file diff --git a/src/services/ReviewService.js b/src/services/ReviewService.js new file mode 100644 index 0000000..236cf87 --- /dev/null +++ b/src/services/ReviewService.js @@ -0,0 +1,460 @@ +/** + * Review Service + * + * Manages Nostr-based mint reviews (NIP-87). + * Ingests, parses, and links reviews to mints. + */ + +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { config } from '../config.js'; +import { normalizeUrl } from '../utils/url.js'; +import { nowISO, unixToISO } from '../utils/time.js'; +import { getMintByUrl, getOrCreateMint } from './MintService.js'; + +/** + * Store raw Nostr event + */ +export function storeNostrEvent(event) { + const existing = queryOne(` + SELECT id FROM nostr_events WHERE event_id = ? + `, [event.id]); + + if (existing) return null; + + run(` + INSERT INTO nostr_events (event_id, kind, pubkey, created_at, content, tags, sig, raw_json, ingested_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + event.id, + event.kind, + event.pubkey, + event.created_at, + event.content, + JSON.stringify(event.tags), + event.sig, + JSON.stringify(event), + nowISO() + ]); + + return event.id; +} + +/** + * Parse and store a review event + */ +export function processReviewEvent(event) { + return transaction(() => { + // Store raw event first + const stored = storeNostrEvent(event); + if (!stored) return null; // Already processed + + // Parse review data + const review = parseReviewEvent(event); + if (!review) return null; + + // Try to link to a mint + let mintId = null; + if (review.mint_url) { + const mint = getMintByUrl(review.mint_url); + if (mint) { + mintId = mint.mint_id; + } else { + // Optionally create new mint from review + try { + const newMint = getOrCreateMint(review.mint_url); + mintId = newMint.mint_id; + } catch (e) { + // Invalid URL, skip linking + } + } + } + + // Store parsed review + run(` + INSERT INTO reviews (event_id, mint_id, mint_url, pubkey, created_at, rating, content, parsed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, [ + event.id, + mintId, + review.mint_url, + event.pubkey, + event.created_at, + review.rating, + review.content, + nowISO() + ]); + + return { + event_id: event.id, + mint_id: mintId, + mint_url: review.mint_url, + pubkey: event.pubkey, + rating: review.rating + }; + }); +} + +/** + * Parse review data from Nostr event (NIP-87) + * + * NIP-87 recommendation structure: + * - kind: 38000 + * - tags: [["u", ""], ["k", "38172"], ["quality", "0/1" or "1/1"]] + * - content: optional review text + */ +function parseReviewEvent(event) { + const tags = event.tags || []; + + // NIP-87: Find mint URL from 'u' tag + let mintUrl = null; + let rating = null; + let isPositive = null; // For quality tag + + for (const tag of tags) { + const [tagName, tagValue] = tag; + + // NIP-87: 'u' tag contains the mint URL + if (tagName === 'u' && tagValue) { + mintUrl = tagValue; + } + + // Alternative: 'd' tag for mint identifier (in announcements) + // Can be a full URL or just domain + if (tagName === 'd' && tagValue) { + if (!mintUrl && (tagValue.includes('.') || tagValue.startsWith('http'))) { + mintUrl = tagValue; + } + } + + // Rating (1-5 scale) + if (tagName === 'rating' && tagValue) { + rating = parseInt(tagValue, 10); + if (isNaN(rating) || rating < 1 || rating > 5) { + rating = null; + } + } + + // NIP-87: "quality" tag - "1/1" = recommend, "0/1" = not recommend + if (tagName === 'quality' && tagValue) { + const parts = tagValue.split('/'); + if (parts.length === 2) { + const num = parseInt(parts[0], 10); + const denom = parseInt(parts[1], 10); + if (!isNaN(num) && !isNaN(denom) && denom > 0) { + // Convert to 1-5 scale: 0/1 = 1, 1/1 = 5 + isPositive = num > 0; + if (rating === null) { + rating = isPositive ? 5 : 1; + } + } + } + } + } + + // Content (review text) + const content = event.content ? event.content.trim() : null; + + if (!mintUrl && !content) { + return null; // Not a useful review + } + + // Normalize the mint URL + let normalizedUrl = null; + if (mintUrl) { + try { + normalizedUrl = normalizeUrl(mintUrl); + } catch (e) { + console.warn(`[ReviewService] Failed to normalize mint URL: ${mintUrl}`); + } + } + + return { + mint_url: normalizedUrl, + rating, + content, + is_positive: isPositive + }; +} + +/** + * Get reviews for a mint + */ +export function getReviews(mintId, options = {}) { + const { limit = 50, offset = 0, since } = options; + + let sql = ` + SELECT + r.event_id, + r.pubkey, + r.created_at, + r.rating, + r.content, + r.parsed_at + FROM reviews r + WHERE r.mint_id = ? + `; + const params = [mintId]; + + if (since) { + sql += ' AND r.created_at >= ?'; + params.push(since); + } + + sql += ' ORDER BY r.created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const reviews = query(sql, params); + + // Convert Unix timestamp to ISO + return reviews.map(r => ({ + ...r, + created_at: unixToISO(r.created_at) + })); +} + +/** + * Get review stats for a mint + */ +export function getReviewStats(mintId) { + return queryOne(` + SELECT + COUNT(*) as total_reviews, + AVG(rating) as average_rating, + COUNT(DISTINCT pubkey) as unique_reviewers, + SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as five_star, + SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as four_star, + SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as three_star, + SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as two_star, + SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as one_star + FROM reviews + WHERE mint_id = ? AND rating IS NOT NULL + `, [mintId]); +} + +/** + * Get unlinked reviews (reviews without mint association) + */ +export function getUnlinkedReviews(limit = 100) { + return query(` + SELECT r.*, ne.raw_json + FROM reviews r + JOIN nostr_events ne ON r.event_id = ne.event_id + WHERE r.mint_id IS NULL AND r.mint_url IS NOT NULL + ORDER BY r.created_at DESC + LIMIT ? + `, [limit]); +} + +/** + * Link review to mint + */ +export function linkReviewToMint(eventId, mintId) { + run(` + UPDATE reviews SET mint_id = ? WHERE event_id = ? + `, [mintId, eventId]); +} + +/** + * Get latest review timestamp for sync + */ +export function getLatestReviewTimestamp() { + const result = queryOne(` + SELECT MAX(created_at) as latest FROM nostr_events WHERE kind IN (?, ?) + `, [config.nip87.cashuMintAnnouncement, config.nip87.recommendation]); + + return result && result.latest ? result.latest : 0; +} + +/** + * Check if we've seen this event + */ +export function hasEvent(eventId) { + const result = queryOne(` + SELECT 1 FROM nostr_events WHERE event_id = ? + `, [eventId]); + + return !!result; +} + +/** + * Get recent reviews across all mints + */ +export function getRecentReviews(limit = 20) { + const reviews = query(` + SELECT + r.event_id, + r.mint_id, + r.pubkey, + r.created_at, + r.rating, + r.content, + m.name as mint_name, + m.canonical_url as mint_url + FROM reviews r + LEFT JOIN mints m ON r.mint_id = m.mint_id + WHERE r.rating IS NOT NULL + ORDER BY r.created_at DESC + LIMIT ? + `, [limit]); + + return reviews.map(r => ({ + ...r, + created_at: unixToISO(r.created_at) + })); +} + +/** + * Get all reviews with filtering (global reviews feed) + */ +export function getAllReviews(options = {}) { + const { mintId, since, until, limit = 50, offset = 0 } = options; + + let sql = ` + SELECT + r.event_id, + r.mint_id, + r.pubkey, + r.created_at, + r.rating, + r.content, + m.name as mint_name, + m.canonical_url as mint_url + FROM reviews r + LEFT JOIN mints m ON r.mint_id = m.mint_id + WHERE 1=1 + `; + const params = []; + + if (mintId) { + sql += ' AND r.mint_id = ?'; + params.push(mintId); + } + + if (since) { + sql += ' AND r.created_at >= ?'; + params.push(since); + } + + if (until) { + sql += ' AND r.created_at <= ?'; + params.push(until); + } + + sql += ' ORDER BY r.created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const reviews = query(sql, params); + + return reviews.map(r => ({ + ...r, + created_at: unixToISO(r.created_at) + })); +} + +/** + * Count total reviews (for pagination) + */ +export function countReviews(options = {}) { + const { mintId, since, until } = options; + + let sql = 'SELECT COUNT(*) as count FROM reviews r WHERE 1=1'; + const params = []; + + if (mintId) { + sql += ' AND r.mint_id = ?'; + params.push(mintId); + } + + if (since) { + sql += ' AND r.created_at >= ?'; + params.push(since); + } + + if (until) { + sql += ' AND r.created_at <= ?'; + params.push(until); + } + + const result = queryOne(sql, params); + return result ? result.count : 0; +} + +/** + * Get review summary for a mint (quick overview) + */ +export function getReviewSummary(mintId) { + const stats = queryOne(` + SELECT + COUNT(*) as total_reviews, + AVG(rating) as avg_rating, + COUNT(DISTINCT pubkey) as unique_reviewers, + SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as five_star, + SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as four_star, + SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as three_star, + SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as two_star, + SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as one_star, + MAX(created_at) as last_review_at + FROM reviews + WHERE mint_id = ? AND rating IS NOT NULL + `, [mintId]); + + if (!stats || stats.total_reviews === 0) { + return { + total_reviews: 0, + avg_rating: null, + rating_distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }, + last_review_at: null + }; + } + + return { + total_reviews: stats.total_reviews, + avg_rating: stats.avg_rating ? Math.round(stats.avg_rating * 10) / 10 : null, + unique_reviewers: stats.unique_reviewers, + rating_distribution: { + 5: stats.five_star || 0, + 4: stats.four_star || 0, + 3: stats.three_star || 0, + 2: stats.two_star || 0, + 1: stats.one_star || 0 + }, + last_review_at: stats.last_review_at ? unixToISO(stats.last_review_at) : null + }; +} + +/** + * Get recent reviews across ecosystem (for homepage feed) + */ +export function getRecentEcosystemReviews(limit = 20, since = null) { + let sql = ` + SELECT + r.event_id, + r.mint_id, + m.name as mint_name, + m.canonical_url as mint_url, + r.pubkey, + r.rating, + SUBSTR(r.content, 1, 200) as excerpt, + r.created_at + FROM reviews r + LEFT JOIN mints m ON r.mint_id = m.mint_id + WHERE r.rating IS NOT NULL + `; + const params = []; + + if (since) { + sql += ' AND r.created_at >= ?'; + params.push(since); + } + + sql += ' ORDER BY r.created_at DESC LIMIT ?'; + params.push(limit); + + const reviews = query(sql, params); + + return reviews.map(r => ({ + ...r, + created_at: unixToISO(r.created_at), + excerpt: r.excerpt ? (r.excerpt.length >= 200 ? r.excerpt + '...' : r.excerpt) : null + })); +} \ No newline at end of file diff --git a/src/services/TrustService.js b/src/services/TrustService.js new file mode 100644 index 0000000..b9727ad --- /dev/null +++ b/src/services/TrustService.js @@ -0,0 +1,462 @@ +/** + * Trust Service + * + * Computes transparent and explainable trust scores. + * Scores are derived from uptime, speed, reviews, and identity signals. + */ + +import { query, queryOne, run } from '../db/connection.js'; +import { config } from '../config.js'; +import { nowISO } from '../utils/time.js'; +import { getUptimeRollup } from './UptimeService.js'; +import { getMetadataSnapshot, deriveFeatures } from './MetadataService.js'; +import { getMintById } from './MintService.js'; + +const { trustWeights } = config; + +/** + * Calculate trust score for a mint + */ +export function calculateTrustScore(mintId) { + const now = nowISO(); + const breakdown = { + uptime: { score: 0, max: trustWeights.uptime, details: {} }, + speed: { score: 0, max: trustWeights.speed, details: {} }, + reviews: { score: 0, max: trustWeights.reviews, details: {} }, + identity: { score: 0, max: trustWeights.identity, details: {} }, + penalties: { score: 0, max: trustWeights.penaltyMax, details: {} } + }; + + // Calculate uptime component (max 40 points) + const uptimeScore = calculateUptimeScore(mintId, breakdown.uptime); + + // Calculate speed component (max 25 points) + const speedScore = calculateSpeedScore(mintId, breakdown.speed); + + // Calculate reviews component (max 20 points) + const reviewsScore = calculateReviewsScore(mintId, breakdown.reviews); + + // Calculate identity component (max 10 points) + const identityScore = calculateIdentityScore(mintId, breakdown.identity); + + // Calculate penalties (up to -15 points) + const penalties = calculatePenalties(mintId, breakdown.penalties); + + // Total score (clamped 0-100) + const totalScore = Math.max(0, Math.min(100, + uptimeScore + speedScore + reviewsScore + identityScore - penalties + )); + + // Determine trust level + const level = getTrustLevel(totalScore); + + // Store the score + run(` + INSERT INTO trust_scores (mint_id, score_total, score_level, breakdown, computed_at) + VALUES (?, ?, ?, ?, ?) + `, [mintId, Math.round(totalScore), level, JSON.stringify(breakdown), now]); + + return { + score_total: Math.round(totalScore), + score_level: level, + breakdown, + computed_at: now + }; +} + +/** + * Calculate uptime score (max 40 points) + */ +function calculateUptimeScore(mintId, component) { + // Use 30-day uptime if available, fallback to 7-day, then 24h + let rollup = getUptimeRollup(mintId, '30d'); + let window = '30d'; + + if (!rollup) { + rollup = getUptimeRollup(mintId, '7d'); + window = '7d'; + } + + if (!rollup) { + rollup = getUptimeRollup(mintId, '24h'); + window = '24h'; + } + + if (!rollup) { + component.details = { reason: 'No uptime data available' }; + return 0; + } + + const uptimePct = rollup.uptime_pct || 0; + component.details = { + window, + uptime_pct: uptimePct, + total_checks: rollup.total_checks + }; + + // Score based on uptime percentage + // 99%+ = full points, scales down from there + if (uptimePct >= 99.9) return trustWeights.uptime; + if (uptimePct >= 99.5) return trustWeights.uptime * 0.95; + if (uptimePct >= 99.0) return trustWeights.uptime * 0.90; + if (uptimePct >= 98.0) return trustWeights.uptime * 0.80; + if (uptimePct >= 95.0) return trustWeights.uptime * 0.65; + if (uptimePct >= 90.0) return trustWeights.uptime * 0.50; + if (uptimePct >= 80.0) return trustWeights.uptime * 0.30; + + return trustWeights.uptime * (uptimePct / 100) * 0.25; +} + +/** + * Calculate speed score (max 25 points) + */ +function calculateSpeedScore(mintId, component) { + const rollup = getUptimeRollup(mintId, '24h') || getUptimeRollup(mintId, '7d'); + + if (!rollup || !rollup.avg_rtt_ms) { + component.details = { reason: 'No speed data available' }; + return 0; + } + + const avgRtt = rollup.avg_rtt_ms; + const p95Rtt = rollup.p95_rtt_ms || avgRtt; + + component.details = { + avg_rtt_ms: Math.round(avgRtt), + p95_rtt_ms: Math.round(p95Rtt) + }; + + // Score based on average RTT + // <200ms = full points, degrades after that + if (avgRtt <= 100) return trustWeights.speed; + if (avgRtt <= 200) return trustWeights.speed * 0.95; + if (avgRtt <= 500) return trustWeights.speed * 0.80; + if (avgRtt <= 1000) return trustWeights.speed * 0.60; + if (avgRtt <= 2000) return trustWeights.speed * 0.40; + if (avgRtt <= 5000) return trustWeights.speed * 0.20; + + return trustWeights.speed * 0.10; +} + +/** + * Calculate reviews score (max 20 points) + */ +function calculateReviewsScore(mintId, component) { + // Get review stats + const stats = queryOne(` + SELECT + COUNT(*) as count, + AVG(rating) as avg_rating, + COUNT(DISTINCT pubkey) as unique_reviewers + FROM reviews + WHERE mint_id = ? AND rating IS NOT NULL + `, [mintId]); + + if (!stats || stats.count === 0) { + component.details = { reason: 'No reviews yet' }; + return 0; + } + + const reviewCount = stats.count; + const avgRating = stats.avg_rating; + const uniqueReviewers = stats.unique_reviewers; + + component.details = { + review_count: reviewCount, + average_rating: Math.round(avgRating * 10) / 10, + unique_reviewers: uniqueReviewers + }; + + // Base score from average rating (1-5 scale) + const ratingScore = ((avgRating - 1) / 4) * (trustWeights.reviews * 0.7); + + // Bonus for review quantity (max 30% of review score) + const quantityBonus = Math.min( + trustWeights.reviews * 0.3, + (Math.log10(reviewCount + 1) / 2) * trustWeights.reviews * 0.3 + ); + + return ratingScore + quantityBonus; +} + +/** + * Calculate identity score (max 10 points) + */ +function calculateIdentityScore(mintId, component) { + const metadata = getMetadataSnapshot(mintId); + const features = deriveFeatures(mintId); + + if (!metadata) { + component.details = { reason: 'No metadata available' }; + return 0; + } + + let score = 0; + const details = {}; + + // Has name (+2) + if (metadata.name) { + score += 2; + details.has_name = true; + } + + // Has pubkey (+3) + if (metadata.pubkey) { + score += 3; + details.has_pubkey = true; + } + + // Has description (+2) + if (metadata.description) { + score += 2; + details.has_description = true; + } + + // Has contact info (+2) + if (metadata.contact && Object.keys(metadata.contact).length > 0) { + score += 2; + details.has_contact = true; + } + + // Has icon (+1) + if (metadata.icon_url) { + score += 1; + details.has_icon = true; + } + + component.details = details; + return Math.min(score, trustWeights.identity); +} + +/** + * Calculate penalties (up to -15 points) + */ +function calculatePenalties(mintId, component) { + const mint = getMintById(mintId); + const rollup = getUptimeRollup(mintId, '7d'); + + let penalties = 0; + const details = {}; + + // Penalty for current offline status (-5) + if (mint.status === 'offline') { + penalties += 5; + details.offline_penalty = 5; + } + + // Penalty for abandoned status (-10) + if (mint.status === 'abandoned') { + penalties += 10; + details.abandoned_penalty = 10; + } + + // Penalty for recent incidents + if (rollup && rollup.incident_count > 0) { + const incidentPenalty = Math.min(5, rollup.incident_count); + penalties += incidentPenalty; + details.incident_penalty = incidentPenalty; + details.incident_count = rollup.incident_count; + } + + // Penalty for high RTT variability (flaky connections) + if (rollup && rollup.p95_rtt_ms && rollup.avg_rtt_ms) { + const variability = rollup.p95_rtt_ms / rollup.avg_rtt_ms; + if (variability > 5) { + penalties += 3; + details.variability_penalty = 3; + } else if (variability > 3) { + penalties += 1; + details.variability_penalty = 1; + } + } + + component.details = details; + component.score = penalties; + + return Math.min(penalties, trustWeights.penaltyMax); +} + +/** + * Determine trust level from score + */ +function getTrustLevel(score) { + if (score >= 85) return 'excellent'; + if (score >= 70) return 'high'; + if (score >= 50) return 'medium'; + if (score >= 25) return 'low'; + return 'unknown'; +} + +/** + * Get current trust score for a mint + */ +export function getTrustScore(mintId) { + const score = queryOne(` + SELECT * FROM current_trust_scores WHERE mint_id = ? + `, [mintId]); + + if (!score) return null; + + return { + score_total: score.score_total, + score_level: score.score_level, + breakdown: JSON.parse(score.breakdown), + computed_at: score.computed_at + }; +} + +/** + * Get trust score history + */ +export function getTrustScoreHistory(mintId, limit = 30) { + return query(` + SELECT score_total, score_level, computed_at + FROM trust_scores + WHERE mint_id = ? + ORDER BY computed_at DESC + LIMIT ? + `, [mintId, limit]); +} + +/** + * Get mints needing trust score recalculation + */ +export function getMintsNeedingTrustScore() { + const sixHoursAgo = new Date(Date.now() - 6 * 3600000).toISOString(); + + return query(` + SELECT DISTINCT m.mint_id + FROM mints m + LEFT JOIN current_trust_scores ts ON m.mint_id = ts.mint_id + WHERE m.status != 'unknown' + AND (ts.computed_at IS NULL OR ts.computed_at < ?) + `, [sixHoursAgo]); +} + +/** + * Get trust score comparison against ecosystem benchmarks + */ +export function getTrustComparison(mintId, against = 'ecosystem') { + // Get mint's score + const mintScore = queryOne(` + SELECT score_total FROM current_trust_scores WHERE mint_id = ? + `, [mintId]); + + if (!mintScore) { + return { + mint_score: null, + benchmark_score: null, + percentile_rank: null, + benchmark_type: against, + error: 'No trust score available for this mint' + }; + } + + let benchmarkScore; + let percentileRank; + + if (against === 'top10') { + // Compare against top 10 mints average + const top10 = queryOne(` + SELECT AVG(score_total) as avg_score + FROM ( + SELECT score_total FROM current_trust_scores + ORDER BY score_total DESC + LIMIT 10 + ) + `); + benchmarkScore = (top10 && top10.avg_score) ? Math.round(top10.avg_score) : null; + } else if (against === 'median') { + // Compare against median + const median = queryOne(` + SELECT score_total as median_score + FROM current_trust_scores + ORDER BY score_total + LIMIT 1 OFFSET (SELECT COUNT(*) / 2 FROM current_trust_scores) + `); + benchmarkScore = (median && median.median_score) ? median.median_score : null; + } else { + // Default: ecosystem average + const ecosystem = queryOne(` + SELECT AVG(score_total) as avg_score FROM current_trust_scores + `); + benchmarkScore = (ecosystem && ecosystem.avg_score) ? Math.round(ecosystem.avg_score) : null; + } + + // Calculate percentile rank + const belowCount = queryOne(` + SELECT COUNT(*) as count FROM current_trust_scores + WHERE score_total < ? + `, [mintScore.score_total]); + + const totalCount = queryOne(` + SELECT COUNT(*) as count FROM current_trust_scores + `); + + if (totalCount && totalCount.count > 0) { + percentileRank = Math.round((belowCount.count / totalCount.count) * 100); + } + + return { + mint_score: mintScore.score_total, + benchmark_score: benchmarkScore, + benchmark_type: against, + percentile_rank: percentileRank, + better_than_percent: percentileRank + }; +} + +/** + * Get detailed trust score history with reasons + */ +export function getTrustScoreHistoryDetailed(mintId, limit = 30) { + const history = query(` + SELECT score_total, score_level, breakdown, computed_at + FROM trust_scores + WHERE mint_id = ? + ORDER BY computed_at DESC + LIMIT ? + `, [mintId, limit]); + + // Process to add reason for changes + return history.map((entry, index) => { + let reason = null; + + if (index < history.length - 1) { + const prev = history[index + 1]; + const diff = entry.score_total - prev.score_total; + + if (Math.abs(diff) >= 5) { + try { + const currBreakdown = JSON.parse(entry.breakdown); + const prevBreakdown = JSON.parse(prev.breakdown); + + // Determine main reason for change + const currUptime = currBreakdown.uptime ? currBreakdown.uptime.score : null; + const prevUptime = prevBreakdown.uptime ? prevBreakdown.uptime.score : null; + const currReviews = currBreakdown.reviews ? currBreakdown.reviews.score : null; + const prevReviews = prevBreakdown.reviews ? prevBreakdown.reviews.score : null; + const currPenalties = currBreakdown.penalties ? currBreakdown.penalties.score : null; + const prevPenalties = prevBreakdown.penalties ? prevBreakdown.penalties.score : null; + + if (currUptime !== prevUptime) { + reason = diff > 0 ? 'uptime_improved' : 'uptime_drop'; + } else if (currReviews !== prevReviews) { + reason = diff > 0 ? 'new_positive_reviews' : 'negative_reviews'; + } else if (currPenalties !== prevPenalties) { + reason = diff > 0 ? 'recovery' : 'new_incident'; + } + } catch (e) { + // Ignore parse errors + } + } + } + + return { + timestamp: entry.computed_at, + score: entry.score_total, + level: entry.score_level, + reason + }; + }); +} \ No newline at end of file diff --git a/src/services/UptimeService.js b/src/services/UptimeService.js new file mode 100644 index 0000000..315f6ed --- /dev/null +++ b/src/services/UptimeService.js @@ -0,0 +1,284 @@ +/** + * Uptime Service + * + * Aggregates probe data into uptime rollups and timeseries. + * Provides uptime percentages and reliability metrics. + */ + +import { query, queryOne, run, transaction } from '../db/connection.js'; +import { nowISO, getPeriodStart, getBucketMs, bucketStart, durationSeconds } from '../utils/time.js'; + +/** + * Calculate and store uptime rollup for a mint + */ +export function calculateUptimeRollup(mintId, window) { + const now = nowISO(); + const periodStart = getPeriodStart(window); + + // Get probe stats for this window + const stats = queryOne(` + SELECT + COUNT(*) as total_checks, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as ok_checks, + AVG(CASE WHEN success = 1 THEN rtt_ms ELSE NULL END) as avg_rtt_ms, + MIN(probed_at) as first_probe, + MAX(probed_at) as last_probe + FROM probes + WHERE mint_id = ? AND probed_at >= ? + `, [mintId, periodStart]); + + if (!stats || stats.total_checks === 0) { + return null; + } + + // Calculate uptime percentage + const uptimePct = (stats.ok_checks / stats.total_checks) * 100; + + // Calculate P95 RTT + const p95Rtt = calculateP95Rtt(mintId, periodStart); + + // Calculate downtime + const downtimeSeconds = calculateDowntime(mintId, periodStart); + + // Count incidents + const incidentResult = queryOne(` + SELECT COUNT(*) as count FROM incidents + WHERE mint_id = ? AND started_at >= ? + `, [mintId, periodStart]); + const incidentCount = incidentResult ? incidentResult.count : 0; + + // Upsert rollup + run(` + INSERT INTO uptime_rollups ( + mint_id, window, period_start, period_end, uptime_pct, + downtime_seconds, avg_rtt_ms, p95_rtt_ms, total_checks, + ok_checks, incident_count, computed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mint_id, window, period_start) DO UPDATE SET + period_end = excluded.period_end, + uptime_pct = excluded.uptime_pct, + downtime_seconds = excluded.downtime_seconds, + avg_rtt_ms = excluded.avg_rtt_ms, + p95_rtt_ms = excluded.p95_rtt_ms, + total_checks = excluded.total_checks, + ok_checks = excluded.ok_checks, + incident_count = excluded.incident_count, + computed_at = excluded.computed_at + `, [ + mintId, window, periodStart, now, uptimePct, + downtimeSeconds, stats.avg_rtt_ms, p95Rtt, stats.total_checks, + stats.ok_checks, incidentCount, now + ]); + + return getUptimeRollup(mintId, window); +} + +/** + * Calculate P95 RTT + */ +function calculateP95Rtt(mintId, since) { + const rtts = query(` + SELECT rtt_ms FROM probes + WHERE mint_id = ? AND probed_at >= ? AND success = 1 AND rtt_ms IS NOT NULL + ORDER BY rtt_ms ASC + `, [mintId, since]); + + if (rtts.length === 0) return null; + + const index = Math.floor(rtts.length * 0.95); + return rtts[Math.min(index, rtts.length - 1)].rtt_ms; +} + +/** + * Calculate total downtime from incidents + */ +function calculateDowntime(mintId, since) { + const result = queryOne(` + SELECT SUM( + CASE + WHEN resolved_at IS NOT NULL THEN duration_seconds + ELSE (strftime('%s', 'now') - strftime('%s', started_at)) + END + ) as total_downtime + FROM incidents + WHERE mint_id = ? AND started_at >= ? + `, [mintId, since]); + + return (result && result.total_downtime) || 0; +} + +/** + * Get uptime rollup for a mint and window + */ +export function getUptimeRollup(mintId, window = '24h') { + return queryOne(` + SELECT * FROM uptime_rollups + WHERE mint_id = ? AND window = ? + ORDER BY computed_at DESC + LIMIT 1 + `, [mintId, window]); +} + +/** + * Get uptime data for API response + */ +export function getUptime(mintId, window = '24h') { + const rollup = getUptimeRollup(mintId, window); + + if (!rollup) { + // Calculate fresh if not exists + return calculateUptimeRollup(mintId, window); + } + + return { + uptime_pct: rollup.uptime_pct, + downtime_seconds: rollup.downtime_seconds, + avg_rtt_ms: rollup.avg_rtt_ms, + p95_rtt_ms: rollup.p95_rtt_ms, + total_checks: rollup.total_checks, + ok_checks: rollup.ok_checks + }; +} + +/** + * Get uptime timeseries for charting + */ +export function getUptimeTimeseries(mintId, window = '24h', bucket = '1h') { + const periodStart = getPeriodStart(window); + const bucketMs = getBucketMs(bucket); + + // Get all probes in window + const probes = query(` + SELECT probed_at, success, rtt_ms + FROM probes + WHERE mint_id = ? AND probed_at >= ? + ORDER BY probed_at ASC + `, [mintId, periodStart]); + + // Group into buckets + const buckets = new Map(); + + for (const probe of probes) { + const bucketKey = bucketStart(probe.probed_at, bucketMs); + + if (!buckets.has(bucketKey)) { + buckets.set(bucketKey, { + timestamp: bucketKey, + total: 0, + ok: 0, + rttSum: 0, + rttCount: 0 + }); + } + + const b = buckets.get(bucketKey); + b.total++; + if (probe.success) { + b.ok++; + if (probe.rtt_ms) { + b.rttSum += probe.rtt_ms; + b.rttCount++; + } + } + } + + // Convert to array + return Array.from(buckets.values()).map(b => ({ + timestamp: b.timestamp, + state: b.ok === b.total ? 'up' : (b.ok === 0 ? 'down' : 'degraded'), + ok: b.ok, + total: b.total, + rtt_ms: b.rttCount > 0 ? Math.round(b.rttSum / b.rttCount) : null + })); +} + +/** + * Calculate rollups for all windows for a mint + */ +export function calculateAllRollups(mintId) { + const windows = ['1h', '24h', '7d', '30d']; + + return transaction(() => { + const rollups = {}; + for (const window of windows) { + rollups[window] = calculateUptimeRollup(mintId, window); + } + return rollups; + }); +} + +/** + * Get quick uptime stats for mint summary + */ +export function getUptimeSummary(mintId) { + const rollups = {}; + + for (const window of['24h', '7d', '30d']) { + const rollup = getUptimeRollup(mintId, window); + rollups[`uptime_${window}`] = rollup ? rollup.uptime_pct : null; + } + + return rollups; +} + +/** + * Get mints needing rollup recalculation + */ +export function getMintsNeedingRollups() { + const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); + + return query(` + SELECT DISTINCT m.mint_id + FROM mints m + LEFT JOIN uptime_rollups ur ON m.mint_id = ur.mint_id AND ur.window = '1h' + WHERE m.status IN ('online', 'degraded', 'offline') + AND (ur.computed_at IS NULL OR ur.computed_at < ?) + `, [oneHourAgo]); +} + +/** + * Get latency/response time timeseries for charting + */ +export function getLatencyTimeseries(mintId, window = '24h', bucket = '1h') { + const periodStart = getPeriodStart(window); + const bucketMs = getBucketMs(bucket); + + // Get successful probes with RTT in window + const probes = query(` + SELECT probed_at, rtt_ms + FROM probes + WHERE mint_id = ? AND probed_at >= ? AND success = 1 AND rtt_ms IS NOT NULL + ORDER BY probed_at ASC + `, [mintId, periodStart]); + + // Group into buckets + const buckets = new Map(); + + for (const probe of probes) { + const bucketKey = bucketStart(probe.probed_at, bucketMs); + + if (!buckets.has(bucketKey)) { + buckets.set(bucketKey, { + timestamp: bucketKey, + rttValues: [] + }); + } + + buckets.get(bucketKey).rttValues.push(probe.rtt_ms); + } + + // Calculate avg and p95 for each bucket + return Array.from(buckets.values()).map(b => { + const sorted = b.rttValues.sort((a, c) => a - c); + const avg = Math.round(sorted.reduce((sum, v) => sum + v, 0) / sorted.length); + const p95Index = Math.floor(sorted.length * 0.95); + const p95 = sorted[Math.min(p95Index, sorted.length - 1)]; + + return { + timestamp: b.timestamp, + avg_rtt_ms: avg, + p95_rtt_ms: p95, + sample_count: sorted.length + }; + }); +} \ No newline at end of file diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 0000000..993227c --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,15 @@ +/** + * Service Exports + * + * Central export point for all services. + */ + +export * from './MintService.js'; +export * from './ProbeService.js'; +export * from './MetadataService.js'; +export * from './UptimeService.js'; +export * from './TrustService.js'; +export * from './ReviewService.js'; +export * from './PageviewService.js'; +export * from './JobService.js'; +export * from './PlausibleService.js'; diff --git a/src/utils/crypto.js b/src/utils/crypto.js new file mode 100644 index 0000000..8f58d05 --- /dev/null +++ b/src/utils/crypto.js @@ -0,0 +1,51 @@ +/** + * Cryptographic Utilities + * + * Hash functions for content deduplication and verification. + */ + +import { createHash } from 'crypto'; + +/** + * Generate SHA-256 hash of content + */ +export function sha256(content) { + if (typeof content !== 'string') { + content = JSON.stringify(content); + } + return createHash('sha256').update(content).digest('hex'); +} + +/** + * Generate content hash for metadata comparison + * Excludes volatile fields like server_time + */ +export function hashMetadata(metadata) { + if (!metadata) return null; + + // Extract stable fields only + const stableFields = { + name: metadata.name, + pubkey: metadata.pubkey, + version: metadata.version, + description: metadata.description, + description_long: metadata.description_long, + contact: metadata.contact, + motd: metadata.motd, + icon_url: metadata.icon_url, + nuts: metadata.nuts, + tos_url: metadata.tos_url, + }; + + // Sort keys for consistent hashing + const sorted = JSON.stringify(stableFields, Object.keys(stableFields).sort()); + return sha256(sorted); +} + +/** + * Generate a short hash for display + */ +export function shortHash(content, length = 8) { + return sha256(content).substring(0, length); +} + diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..c8618e5 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,10 @@ +/** + * Utility Exports + * + * Central export point for all utilities. + */ + +export * from './url.js'; +export * from './crypto.js'; +export * from './time.js'; + diff --git a/src/utils/time.js b/src/utils/time.js new file mode 100644 index 0000000..2078287 --- /dev/null +++ b/src/utils/time.js @@ -0,0 +1,124 @@ +/** + * Time Utilities + * + * ISO-8601 UTC timestamps for consistency. + */ + +/** + * Get current UTC timestamp in ISO-8601 format + */ +export function nowISO() { + return new Date().toISOString(); +} + +/** + * Get current Unix timestamp (seconds) + */ +export function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +/** + * Parse ISO timestamp to Date + */ +export function parseISO(isoString) { + return new Date(isoString); +} + +/** + * Format date to ISO-8601 + */ +export function toISO(date) { + if (!date) return null; + if (typeof date === 'string') return date; + return date.toISOString(); +} + +/** + * Unix timestamp to ISO + */ +export function unixToISO(unix) { + return new Date(unix * 1000).toISOString(); +} + +/** + * ISO to Unix timestamp + */ +export function isoToUnix(iso) { + return Math.floor(new Date(iso).getTime() / 1000); +} + +/** + * Get timestamp X milliseconds ago + */ +export function ago(ms) { + return new Date(Date.now() - ms).toISOString(); +} + +/** + * Get timestamp X days ago + */ +export function daysAgo(days) { + return ago(days * 24 * 60 * 60 * 1000); +} + +/** + * Get timestamp X hours ago + */ +export function hoursAgo(hours) { + return ago(hours * 60 * 60 * 1000); +} + +/** + * Calculate duration in seconds between two ISO timestamps + */ +export function durationSeconds(start, end) { + const startDate = new Date(start); + const endDate = end ? new Date(end) : new Date(); + return Math.floor((endDate - startDate) / 1000); +} + +/** + * Get start of period for rollup calculations + */ +export function getPeriodStart(window) { + const now = new Date(); + + switch (window) { + case '1h': + return new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + case '24h': + return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + case '7d': + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + case '30d': + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + default: + return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + } +} + +/** + * Get bucket interval in milliseconds + */ +export function getBucketMs(bucket) { + switch (bucket) { + case '5m': + return 5 * 60 * 1000; + case '15m': + return 15 * 60 * 1000; + case '1h': + return 60 * 60 * 1000; + default: + return 60 * 60 * 1000; + } +} + +/** + * Round timestamp down to bucket start + */ +export function bucketStart(timestamp, bucketMs) { + const ts = new Date(timestamp).getTime(); + return new Date(Math.floor(ts / bucketMs) * bucketMs).toISOString(); +} + diff --git a/src/utils/url.js b/src/utils/url.js new file mode 100644 index 0000000..9996448 --- /dev/null +++ b/src/utils/url.js @@ -0,0 +1,176 @@ +/** + * URL Normalization Utilities + * + * Ensures consistent URL handling across the system. + * Rules: + * - Store URLs WITHOUT protocol (e.g., "mint.example.com/path") + * - Accept input with or without protocol + * - Lowercase hostname + * - Remove trailing slashes + * - Strip default ports + */ + +/** + * Normalize a mint URL for consistent identity resolution + * Returns URL WITHOUT protocol for storage (e.g., "mint.example.com") + */ +export function normalizeUrl(urlString) { + if (!urlString) return null; + + try { + let input = urlString.trim(); + + // Add protocol if missing (needed for URL parsing) + if (!input.startsWith('http://') && !input.startsWith('https://')) { + // Check if it's a .onion address + const isTor = input.includes('.onion'); + input = isTor ? `http://${input}` : `https://${input}`; + } + + let url = new URL(input); + + // Force lowercase hostname + url.hostname = url.hostname.toLowerCase(); + + // Determine URL type + const isTor = url.hostname.endsWith('.onion'); + const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + + // Strip default ports + if ((url.protocol === 'https:' && url.port === '443') || + (url.protocol === 'http:' && url.port === '80')) { + url.port = ''; + } + + // Build normalized URL WITHOUT protocol + let normalized = url.hostname; + + // Add non-default port + if (url.port) { + normalized += `:${url.port}`; + } + + // Add path (remove trailing slash) + let path = url.pathname; + if (path.endsWith('/') && path.length > 1) { + path = path.slice(0, -1); + } + if (path !== '/') { + normalized += path; + } + + return normalized; + } catch (error) { + console.error(`[URL] Failed to normalize: ${urlString}`, error.message); + return null; + } +} + +/** + * Convert stored URL (without protocol) to full URL for HTTP requests + */ +export function toFullUrl(storedUrl) { + if (!storedUrl) return null; + + // Already has protocol + if (storedUrl.startsWith('http://') || storedUrl.startsWith('https://')) { + return storedUrl; + } + + // .onion uses http + if (storedUrl.includes('.onion')) { + return `http://${storedUrl}`; + } + + // Default to https + return `https://${storedUrl}`; +} + +/** + * Detect URL type + */ +export function getUrlType(urlString) { + if (!urlString) return 'clearnet'; + + const normalized = urlString.toLowerCase(); + + if (normalized.includes('.onion')) { + return 'tor'; + } + + return 'clearnet'; +} + +/** + * Check if URL is valid + */ +export function isValidUrl(urlString) { + if (!urlString) return false; + + try { + // Add protocol if needed for validation + let input = urlString.trim(); + if (!input.startsWith('http://') && !input.startsWith('https://')) { + input = `https://${input}`; + } + + const url = new URL(input); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Extract hostname from URL + */ +export function getHostname(urlString) { + if (!urlString) return null; + + try { + let input = urlString.trim(); + if (!input.startsWith('http://') && !input.startsWith('https://')) { + input = `https://${input}`; + } + + const url = new URL(input); + return url.hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Build full endpoint URL for HTTP requests + */ +export function buildEndpointUrl(baseUrl, endpoint) { + if (!baseUrl) return null; + + try { + // Convert stored URL to full URL + const fullUrl = toFullUrl(baseUrl); + const base = new URL(fullUrl); + const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + // Remove trailing slash from base path + let basePath = base.pathname; + if (basePath.endsWith('/')) { + basePath = basePath.slice(0, -1); + } + + base.pathname = basePath + path; + return base.toString(); + } catch { + return null; + } +} + +/** + * Check if two URLs point to the same mint + */ +export function urlsMatch(url1, url2) { + const normalized1 = normalizeUrl(url1); + const normalized2 = normalizeUrl(url2); + + return normalized1 && normalized2 && normalized1 === normalized2; +} \ No newline at end of file diff --git a/src/workers/index.js b/src/workers/index.js new file mode 100644 index 0000000..e3a957a --- /dev/null +++ b/src/workers/index.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Combined Worker Runner + * + * Runs all workers in a single process for simpler deployment. + */ + +import { initDatabase } from '../db/connection.js'; +import { runProbeWorker } from './probe.js'; +import { runMetadataWorker } from './metadata.js'; +import { runNostrWorker } from './nostr.js'; +import { runRollupWorker } from './rollup.js'; +import { runTrustWorker } from './trust.js'; +import { cleanupOldJobs, resetStuckJobs } from '../services/JobService.js'; +import { cleanupOldPageviews } from '../services/PageviewService.js'; + +async function runAllWorkers() { + console.log('[Workers] Starting all workers...'); + initDatabase(); + + // Initial cleanup + cleanupOldJobs(7); + resetStuckJobs(30); + cleanupOldPageviews(); + + // Start all workers concurrently + const workers = [ + { name: 'Probe', fn: runProbeWorker }, + { name: 'Metadata', fn: runMetadataWorker }, + { name: 'Nostr', fn: runNostrWorker }, + { name: 'Rollup', fn: runRollupWorker }, + { name: 'Trust', fn: runTrustWorker }, + { name: 'Cleanup', fn: runCleanupWorker } + ]; + + // Run workers with restart on failure + for (const worker of workers) { + runWithRestart(worker.name, worker.fn); + } + + // Keep process alive + process.on('SIGINT', () => { + console.log('[Workers] Shutting down...'); + process.exit(0); + }); + + process.on('SIGTERM', () => { + console.log('[Workers] Shutting down...'); + process.exit(0); + }); +} + +async function runWithRestart(name, fn) { + while (true) { + try { + console.log(`[Workers] Starting ${name} worker...`); + await fn(); + } catch (error) { + console.error(`[Workers] ${name} worker crashed:`, error); + console.log(`[Workers] Restarting ${name} worker in 10s...`); + await sleep(10000); + } + } +} + +async function runCleanupWorker() { + const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // Daily + + while (true) { + try { + console.log('[CleanupWorker] Running cleanup...'); + + const deletedJobs = cleanupOldJobs(7); + console.log(`[CleanupWorker] Deleted ${deletedJobs} old jobs`); + + const resetJobs = resetStuckJobs(30); + console.log(`[CleanupWorker] Reset ${resetJobs} stuck jobs`); + + const deletedPageviews = cleanupOldPageviews(); + console.log(`[CleanupWorker] Deleted ${deletedPageviews} old pageviews`); + + await sleep(CLEANUP_INTERVAL); + } catch (error) { + console.error('[CleanupWorker] Error:', error); + await sleep(CLEANUP_INTERVAL); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Run if called directly +runAllWorkers().catch(console.error); + +export { runAllWorkers }; + diff --git a/src/workers/metadata.js b/src/workers/metadata.js new file mode 100644 index 0000000..5648e69 --- /dev/null +++ b/src/workers/metadata.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +/** + * Metadata Worker + * + * Fetches and updates mint metadata (NUT-06). + * Implements rate limiting and backoff for failed mints. + */ + +import { initDatabase } from '../db/connection.js'; +import { getMintsNeedingMetadata, fetchMintMetadata } from '../services/MetadataService.js'; + +const BATCH_SIZE = 3; // Reduced batch size +const POLL_INTERVAL = 120000; // 2 minutes between polls +const REQUEST_DELAY = 2000; // 2 seconds between individual requests +const BATCH_DELAY = 5000; // 5 seconds between batches +const ERROR_BACKOFF = 300000; // 5 minutes backoff for failed mints + +// Track recently failed mints to avoid hammering +const failedMints = new Map(); // mintId -> timestamp of last failure + +async function runMetadataWorker() { + console.log('[MetadataWorker] Starting...'); + initDatabase(); + + while (true) { + try { + const mintsToFetch = getMintsNeedingMetadata(); + + // Filter out recently failed mints + const now = Date.now(); + const eligibleMints = mintsToFetch.filter(mint => { + const lastFailure = failedMints.get(mint.mint_id); + if (lastFailure && now - lastFailure < ERROR_BACKOFF) { + return false; // Skip, still in backoff + } + return true; + }); + + if (eligibleMints.length === 0) { + await sleep(POLL_INTERVAL); + continue; + } + + console.log(`[MetadataWorker] Found ${eligibleMints.length} mints needing metadata`); + + // Process sequentially with delays to avoid rate limits + for (let i = 0; i < eligibleMints.length; i += BATCH_SIZE) { + const batch = eligibleMints.slice(i, i + BATCH_SIZE); + + // Process batch items sequentially + for (const mint of batch) { + try { + const result = await fetchMintMetadata(mint.mint_id); + if (result) { + console.log(`[MetadataWorker] Updated metadata for ${mint.canonical_url}`); + failedMints.delete(mint.mint_id); // Clear any previous failure + } + } catch (error) { + console.error(`[MetadataWorker] Error fetching ${mint.canonical_url}:`, error.message); + failedMints.set(mint.mint_id, Date.now()); // Track failure + } + + // Delay between individual requests + await sleep(REQUEST_DELAY); + } + + // Delay between batches + if (i + BATCH_SIZE < eligibleMints.length) { + await sleep(BATCH_DELAY); + } + } + + // Wait before next poll cycle + await sleep(POLL_INTERVAL); + } catch (error) { + console.error('[MetadataWorker] Error:', error); + await sleep(POLL_INTERVAL); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Clean up old failures periodically (every 30 minutes) +setInterval(() => { + const now = Date.now(); + for (const [mintId, timestamp] of failedMints) { + if (now - timestamp > ERROR_BACKOFF * 2) { + failedMints.delete(mintId); + } + } +}, 1800000); + +// Run if called directly +if (process.argv[1].includes('metadata.js')) { + runMetadataWorker().catch(console.error); +} + +export { runMetadataWorker }; \ No newline at end of file diff --git a/src/workers/nostr.js b/src/workers/nostr.js new file mode 100644 index 0000000..54d5d13 --- /dev/null +++ b/src/workers/nostr.js @@ -0,0 +1,333 @@ +#!/usr/bin/env node + +/** + * Nostr Worker + * + * Ingests mint reviews and recommendations from Nostr relays (NIP-87). + * + * NIP-87 Event Kinds: + * - 38172: Cashu mint announcement (from mint operators) + * - 38173: Fedimint announcement (from mint operators) + * - 38000: Recommendation/review (from users) - uses #k tag to specify target kind + */ + +import { Relay, useWebSocketImplementation } from 'nostr-tools/relay'; +import WebSocket from 'ws'; +import { initDatabase, queryOne } from '../db/connection.js'; +import { config } from '../config.js'; +import { processReviewEvent, getLatestReviewTimestamp, hasEvent } from '../services/ReviewService.js'; +import { getOrCreateMint } from '../services/MintService.js'; +import { normalizeUrl } from '../utils/url.js'; + +// Use ws for WebSocket in Node.js +useWebSocketImplementation(WebSocket); + +const POLL_INTERVAL = 300000; // 5 minutes between polls +const RELAY_DELAY = 5000; // 5 seconds between relay connections +const QUERY_DELAY = 2000; // 2 seconds between queries on same relay +const QUERY_TIMEOUT = 30000; // 30 seconds for query + +// Historical backfill settings +const HISTORICAL_YEARS = 2.5; // Fetch from 2.5 years ago on fresh database +const BATCH_DAYS = 180; // Fetch in 6-month batches +const BATCH_DELAY = 5000; // 5 seconds between historical batches + +// NIP-87 event kinds +const CASHU_MINT_ANNOUNCEMENT = 38172; +const RECOMMENDATION = 38000; + +/** + * Check if database has any nostr events (fresh database detection) + */ +function isFreshDatabase() { + const result = queryOne('SELECT COUNT(*) as count FROM nostr_events'); + return !result || result.count === 0; +} + +async function runNostrWorker() { + console.log('[NostrWorker] Starting...'); + initDatabase(); + + if (config.nostrRelays.length === 0) { + console.warn('[NostrWorker] No relays configured, exiting'); + return; + } + + // Check if this is a fresh database - do historical backfill + if (isFreshDatabase()) { + console.log('[NostrWorker] Fresh database detected - starting historical backfill...'); + await runHistoricalBackfill(); + console.log('[NostrWorker] Historical backfill completed'); + } + + while (true) { + try { + await fetchFromAllRelays(); + } catch (error) { + console.error('[NostrWorker] Error:', error.message); + } + + console.log(`[NostrWorker] Next poll in ${POLL_INTERVAL / 1000}s...`); + await sleep(POLL_INTERVAL); + } +} + +/** + * Historical backfill - fetch reviews in batches going back 2+ years + */ +async function runHistoricalBackfill() { + const now = Math.floor(Date.now() / 1000); + const startTime = now - Math.floor(HISTORICAL_YEARS * 365 * 24 * 60 * 60); + const batchSeconds = BATCH_DAYS * 24 * 60 * 60; + + console.log(`[NostrWorker] Backfilling from ${new Date(startTime * 1000).toISOString()} to now`); + + let batchStart = startTime; + let batchNumber = 0; + let totalEvents = 0; + + while (batchStart < now) { + batchNumber++; + const batchEnd = Math.min(batchStart + batchSeconds, now); + + console.log(`[NostrWorker] Historical batch ${batchNumber}: ${new Date(batchStart * 1000).toISOString()} to ${new Date(batchEnd * 1000).toISOString()}`); + + const events = await fetchHistoricalBatch(batchStart, batchEnd); + totalEvents += events; + + console.log(`[NostrWorker] Batch ${batchNumber} complete: ${events} events (total: ${totalEvents})`); + + batchStart = batchEnd; + + // Delay between batches to avoid rate limiting + if (batchStart < now) { + await sleep(BATCH_DELAY); + } + } + + console.log(`[NostrWorker] Historical backfill complete: ${totalEvents} total events from ${batchNumber} batches`); +} + +/** + * Fetch a single historical batch from all relays + */ +async function fetchHistoricalBatch(since, until) { + let totalEvents = 0; + + for (let i = 0; i < config.nostrRelays.length; i++) { + const relayUrl = config.nostrRelays[i]; + try { + const events = await fetchFromRelay(relayUrl, since, until); + totalEvents += events; + } catch (error) { + console.error(`[NostrWorker] ${relayUrl} (historical): ${error.message}`); + } + + if (i < config.nostrRelays.length - 1) { + await sleep(RELAY_DELAY); + } + } + + return totalEvents; +} + +async function fetchFromAllRelays() { + const since = getLatestReviewTimestamp() || Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + console.log(`[NostrWorker] Fetching since ${new Date(since * 1000).toISOString()}`); + + let totalEvents = 0; + + // Process each relay individually with delays to avoid rate limits + for (let i = 0; i < config.nostrRelays.length; i++) { + const relayUrl = config.nostrRelays[i]; + try { + const events = await fetchFromRelay(relayUrl, since); + totalEvents += events; + } catch (error) { + console.error(`[NostrWorker] ${relayUrl}: ${error.message}`); + } + + // Delay between relay connections + if (i < config.nostrRelays.length - 1) { + await sleep(RELAY_DELAY); + } + } + + console.log(`[NostrWorker] Total new events: ${totalEvents}`); +} + +/** + * Fetch events from a single relay + * @param {string} relayUrl - Relay URL to connect to + * @param {number} since - Unix timestamp to fetch from + * @param {number} [until] - Optional Unix timestamp to fetch until (for historical batches) + */ +async function fetchFromRelay(relayUrl, since, until = null) { + let relay; + let eventCount = 0; + + try { + // Connect to relay + relay = await Relay.connect(relayUrl); + console.log(`[NostrWorker] Connected to ${relayUrl}`); + + // Build filter with optional until for historical batches + const baseFilter = { + kinds: [CASHU_MINT_ANNOUNCEMENT], + since: since, + limit: 500 // Increase limit for historical fetches + }; + if (until) { + baseFilter.until = until; + } + + // Fetch announcements (kind 38172) + const announcements = await queryRelay(relay, baseFilter); + + for (const event of announcements) { + if (!hasEvent(event.id)) { + processEvent(event); + eventCount++; + } + } + + // Delay between queries on same relay + await sleep(QUERY_DELAY); + + // Build recommendation filter with optional until + const recFilter = { + kinds: [RECOMMENDATION], + '#k': [String(CASHU_MINT_ANNOUNCEMENT)], + since: since, + limit: 500 + }; + if (until) { + recFilter.until = until; + } + + // Fetch recommendations (kind 38000 with #k tag) + const recommendations = await queryRelay(relay, recFilter); + + for (const event of recommendations) { + if (!hasEvent(event.id)) { + processEvent(event); + eventCount++; + } + } + + console.log(`[NostrWorker] ${relayUrl}: ${eventCount} new events`); + + } finally { + if (relay) { + relay.close(); + } + } + + return eventCount; +} + +function queryRelay(relay, filter) { + return new Promise((resolve, reject) => { + const events = []; + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + sub.close(); + resolve(events); + } + }, QUERY_TIMEOUT); + + const sub = relay.subscribe([filter], { + onevent(event) { + events.push(event); + }, + oneose() { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + sub.close(); + resolve(events); + } + }, + onclose(reason) { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(events); + } + } + }); + }); +} + +function processEvent(event) { + console.log(`[NostrWorker] Processing ${event.id.substring(0, 8)}... (kind: ${event.kind})`); + + switch (event.kind) { + case CASHU_MINT_ANNOUNCEMENT: + processMintAnnouncement(event); + break; + + case RECOMMENDATION: + processReviewEvent(event); + break; + + default: + console.log(`[NostrWorker] Unknown kind: ${event.kind}`); + } +} + +function processMintAnnouncement(event) { + let mintUrl = null; + + for (const tag of event.tags || []) { + const [tagName, tagValue] = tag; + // Look for 'd' tag (unique identifier) or 'u' tag (URL) + // Can be full URL or just domain + if ((tagName === 'd' || tagName === 'u') && tagValue) { + if (tagValue.includes('.') || tagValue.startsWith('http')) { + mintUrl = tagValue; + break; + } + } + } + + if (!mintUrl && event.content) { + const urlMatch = event.content.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + mintUrl = urlMatch[0]; + } + } + + if (mintUrl) { + try { + const normalizedUrl = normalizeUrl(mintUrl); + if (normalizedUrl) { + const mint = getOrCreateMint(normalizedUrl); + console.log(`[NostrWorker] Discovered mint: ${mint.canonical_url}`); + } + } catch (error) { + console.error(`[NostrWorker] Error creating mint:`, error.message); + } + } + + processReviewEvent(event); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Run if called directly +const isMainModule = process.argv[1] && ( + process.argv[1].endsWith('nostr.js') || + process.argv[1].includes('workers/nostr') +); + +if (isMainModule) { + runNostrWorker().catch(console.error); +} + +export { runNostrWorker }; \ No newline at end of file diff --git a/src/workers/probe.js b/src/workers/probe.js new file mode 100644 index 0000000..fc2d1c7 --- /dev/null +++ b/src/workers/probe.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * Probe Worker + * + * Continuously probes mints for availability and response times. + * Implements rate limiting to avoid overwhelming mints. + */ + +import { initDatabase } from '../db/connection.js'; +import { getMintsToProbe, probeMint } from '../services/ProbeService.js'; + +const BATCH_SIZE = 5; // Reduced batch size +const POLL_INTERVAL = 30000; // 30 seconds between polls +const REQUEST_DELAY = 1000; // 1 second between individual requests +const BATCH_DELAY = 3000; // 3 seconds between batches + +async function runProbeWorker() { + console.log('[ProbeWorker] Starting...'); + initDatabase(); + + while (true) { + try { + const mintsToProbe = getMintsToProbe(); + + if (mintsToProbe.length === 0) { + await sleep(POLL_INTERVAL); + continue; + } + + console.log(`[ProbeWorker] Found ${mintsToProbe.length} mints to probe`); + + // Process in batches with delays + for (let i = 0; i < mintsToProbe.length; i += BATCH_SIZE) { + const batch = mintsToProbe.slice(i, i + BATCH_SIZE); + + // Process batch items sequentially to avoid overwhelming targets + for (const mint of batch) { + try { + const result = await probeMint(mint.mint_id); + console.log(`[ProbeWorker] ${mint.canonical_url} - ${result.success ? 'OK' : 'FAIL'} (${result.rtt_ms}ms)`); + } catch (error) { + console.error(`[ProbeWorker] Error probing ${mint.canonical_url}:`, error.message); + } + + // Delay between individual requests + await sleep(REQUEST_DELAY); + } + + // Delay between batches + if (i + BATCH_SIZE < mintsToProbe.length) { + await sleep(BATCH_DELAY); + } + } + + // Wait before next poll cycle + await sleep(POLL_INTERVAL); + } catch (error) { + console.error('[ProbeWorker] Error:', error); + await sleep(POLL_INTERVAL); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Run if called directly +if (process.argv[1].includes('probe.js')) { + runProbeWorker().catch(console.error); +} + +export { runProbeWorker }; \ No newline at end of file diff --git a/src/workers/rollup.js b/src/workers/rollup.js new file mode 100644 index 0000000..f123bbe --- /dev/null +++ b/src/workers/rollup.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Rollup Worker + * + * Calculates uptime rollups and pageview aggregations. + */ + +import { initDatabase } from '../db/connection.js'; +import { getMintsNeedingRollups, calculateAllRollups } from '../services/UptimeService.js'; +import { query } from '../db/connection.js'; + +const POLL_INTERVAL = 300000; // 5 minutes + +async function runRollupWorker() { + console.log('[RollupWorker] Starting...'); + initDatabase(); + + while (true) { + try { + // Calculate uptime rollups + const mintsForRollup = getMintsNeedingRollups(); + + if (mintsForRollup.length > 0) { + console.log(`[RollupWorker] Calculating rollups for ${mintsForRollup.length} mints`); + + for (const { mint_id } of mintsForRollup) { + try { + calculateAllRollups(mint_id); + console.log(`[RollupWorker] Calculated rollups for ${mint_id}`); + } catch (error) { + console.error(`[RollupWorker] Error calculating rollups for ${mint_id}:`, error.message); + } + } + } + + await sleep(POLL_INTERVAL); + } catch (error) { + console.error('[RollupWorker] Error:', error); + await sleep(POLL_INTERVAL); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Run if called directly +if (process.argv[1].includes('rollup.js')) { + runRollupWorker().catch(console.error); +} + +export { runRollupWorker }; + diff --git a/src/workers/trust.js b/src/workers/trust.js new file mode 100644 index 0000000..1b4e196 --- /dev/null +++ b/src/workers/trust.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Trust Score Worker + * + * Calculates and updates trust scores for all mints. + */ + +import { initDatabase } from '../db/connection.js'; +import { getMintsNeedingTrustScore, calculateTrustScore } from '../services/TrustService.js'; + +const POLL_INTERVAL = 600000; // 10 minutes + +async function runTrustWorker() { + console.log('[TrustWorker] Starting...'); + initDatabase(); + + while (true) { + try { + const mintsForScore = getMintsNeedingTrustScore(); + + if (mintsForScore.length > 0) { + console.log(`[TrustWorker] Calculating trust scores for ${mintsForScore.length} mints`); + + for (const { mint_id } of mintsForScore) { + try { + const score = calculateTrustScore(mint_id); + console.log(`[TrustWorker] ${mint_id}: ${score.score_total} (${score.score_level})`); + } catch (error) { + console.error(`[TrustWorker] Error calculating score for ${mint_id}:`, error.message); + } + } + } + + await sleep(POLL_INTERVAL); + } catch (error) { + console.error('[TrustWorker] Error:', error); + await sleep(POLL_INTERVAL); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Run if called directly +if (process.argv[1].includes('trust.js')) { + runTrustWorker().catch(console.error); +} + +export { runTrustWorker }; + diff --git a/starter-docs/admin_endpoints.md b/starter-docs/admin_endpoints.md new file mode 100644 index 0000000..add2e97 --- /dev/null +++ b/starter-docs/admin_endpoints.md @@ -0,0 +1,327 @@ +# Cashumints.space – Admin Endpoints + +This document defines the **admin-only API surface** of Cashumints.space. + +These endpoints exist to: + +* curate mint data safely +* correct discovery or URL issues +* resolve identity conflicts +* force recomputation when rules change +* debug the background system + +Admin endpoints **never fabricate reality**. They annotate, correct routing, or trigger recomputation, but they do not delete historical data. + +--- + +## General Rules + +* Base path: `/v1/admin` +* Authentication: `ADMIN_API_KEY` (static header) +* All admin actions are **audited** +* No admin endpoint deletes raw data +* Every mutation writes to the admin audit log + +Audit log fields: + +* admin_id +* action +* target +* before_state +* after_state +* timestamp + +--- + +## POST /v1/admin/mints + +### Purpose + +Manually add a mint to the system. + +Used for: + +* trusted bootstrap +* known operators +* recovery when auto-discovery fails + +### Request body + +``` +{ + "mint_url": "https://mint.example", + "notes": "optional internal notes" +} +``` + +### Logic + +1. Normalize the URL +2. Check if URL already exists in `mint_urls` +3. If exists: + + * return existing mint_id +4. If not: + + * create new mint record + * status = `unknown` + * discovered_from = `manual` + * create mint_url entry (active) +5. Enqueue immediate probe job + +### Response + +``` +{ + "mint_id": "uuid", + "status": "unknown", + "message": "Mint added and scheduled for probing" +} +``` + +--- + +## POST /v1/admin/mints/{mint_id}/urls + +### Purpose + +Manually attach an additional URL (clearnet, Tor, mirror) to an existing mint. + +### Request body + +``` +{ + "url": "http://example.onion", + "type": "tor", + "active": true +} +``` + +### Logic + +1. Normalize URL +2. Ensure URL is not linked to another mint +3. Create mint_urls entry +4. Mark source = `admin` +5. Enqueue probe for new URL + +### Rules + +* URLs are never deleted +* If duplicate URL exists, return 409 + +--- + +## POST /v1/admin/mints/merge + +### Purpose + +Merge two mints that represent the same operator. + +### Request body + +``` +{ + "source_mint_id": "uuid", + "target_mint_id": "uuid", + "reason": "Same pubkey, different URLs" +} +``` + +### Logic + +1. Validate both mint IDs exist +2. Ensure source != target +3. Create merge record +4. Reassign all related data: + + * mint_urls + * probes + * metadata history + * reviews + * rollups +5. Mark source mint as `merged` +6. Target mint becomes canonical + +### Rules + +* No data is deleted +* Merge is reversible + +--- + +## POST /v1/admin/mints/split + +### Purpose + +Undo a previous mint merge. + +### Request body + +``` +{ + "merge_id": "uuid" +} +``` + +### Logic + +1. Load merge record +2. Restore original mint records +3. Reassign data back to original mint +4. Mark merge record as reverted + +### Rules + +* Only one split per merge +* Full restoration required + +--- + +## POST /v1/admin/mints/{mint_id}/disable + +### Purpose + +Hide a mint from public listings without deleting it. + +### Logic + +1. Set mint.visibility = `hidden` +2. Mint continues to be probed +3. Mint remains accessible by direct ID or URL + +### Use cases + +* scam submissions +* spam mints +* broken test instances + +--- + +## POST /v1/admin/mints/{mint_id}/enable + +### Purpose + +Re-enable a previously hidden mint. + +### Logic + +1. Set mint.visibility = `public` +2. No other state changes + +--- + +## POST /v1/admin/mints/{mint_id}/metadata/refresh + +### Purpose + +Force metadata fetch, bypassing the hourly limit. + +### Logic + +1. Enqueue metadata fetch job +2. Ignore METADATA_FETCH_INTERVAL +3. Metadata still validated normally + +### Rules + +* Does not fabricate metadata +* Failure does not erase existing metadata + +--- + +## POST /v1/admin/mints/{mint_id}/trust/recompute + +### Purpose + +Force trust score recomputation. + +### Logic + +1. Enqueue trust recompute job +2. Uses current rollups, reviews, metadata +3. Stores new score and breakdown + +### Use cases + +* scoring logic changes +* review moderation +* metadata correction + +--- + +## GET /v1/admin/jobs + +### Purpose + +Inspect the background job queue. + +### Returns + +``` +[ + { + "job_id": "uuid", + "type": "probe_mint", + "status": "pending", + "run_at": "timestamp", + "attempts": 2, + "last_error": null + } +] +``` + +### Rules + +* Read-only +* No mutation + +--- + +## GET /v1/admin/system/metrics + +### Purpose + +High-level system health view. + +### Returns + +* total_mints +* online_mints +* offline_mints +* probes_last_minute +* failed_probes_last_minute +* job_backlog +* oldest_job_age_seconds +* database_size_mb +* worker_heartbeat + +Used for operations and debugging. + +--- + +## POST /v1/admin/mints/{mint_id}/status/reset + +### Purpose + +Clear a stuck mint state caused by transient infrastructure issues. + +### Logic + +1. Reset consecutive_failures to 0 +2. Clear offline_since +3. Do NOT change historical probes +4. Enqueue immediate probe + +### Rules + +* Does not fabricate uptime +* Next probe determines real status + +--- + +## Final Admin Rule + +Admins may correct **structure and routing**, but never rewrite **history or truth**. + +If an action cannot be explained in the audit log, it does not belong in the admin API. diff --git a/starter-docs/endpoints.md b/starter-docs/endpoints.md new file mode 100644 index 0000000..fe5d969 --- /dev/null +++ b/starter-docs/endpoints.md @@ -0,0 +1,229 @@ +# Cashumints.space API – Endpoints + +Base path: + +/v1 + +All endpoints are read‑only unless stated otherwise. +All timestamps are ISO‑8601 UTC. +All mint‑specific endpoints accept either a mint ID or a mint URL. + +--- + +## Resolution Rules + +Endpoints support: +- `/v1/mints/{mint_id}` +- `/v1/mints/by-url?url=...` + +The API resolves URLs to the canonical mint_id internally. + +--- + +## Single Mint – Core + +GET /v1/mints/{mint_id} +GET /v1/mints/by-url?url= + +Returns: +- mint_id +- canonical_url +- urls[] +- name +- icon_url +- status +- offline_since +- last_success_at +- last_failure_at +- uptime_24h +- uptime_7d +- uptime_30d +- incidents_7d +- incidents_30d +- trust_score +- trust_level + +--- + +## Mint URLs + +GET /v1/mints/{mint_id}/urls +GET /v1/mints/by-url/urls?url= + +Returns: +- canonical_url +- urls[]: + - url + - type (clearnet | tor | mirror) + - active + +--- + +## Metadata (NUT‑06) + +GET /v1/mints/{mint_id}/metadata +GET /v1/mints/by-url/metadata?url= + +Returns: +- name +- pubkey +- version +- description +- description_long +- contact +- motd +- icon_url +- urls +- tos_url +- nuts +- server_time +- last_fetched_at + +--- + +GET /v1/mints/{mint_id}/metadata/history +GET /v1/mints/by-url/metadata/history?url= + +Returns: +- fetched_at +- change_type +- diff +- version + +--- + +## Status (Lightweight) + +GET /v1/mints/{mint_id}/status +GET /v1/mints/by-url/status?url= + +Returns: +- status +- offline_since +- last_checked_at +- current_rtt_ms + +--- + +## Uptime & Reliability + +GET /v1/mints/{mint_id}/uptime +GET /v1/mints/by-url/uptime?url= + +Query: +- window=24h | 7d | 30d + +Returns: +- uptime_pct +- downtime_seconds +- avg_rtt_ms +- p95_rtt_ms +- total_checks +- ok_checks + +--- + +GET /v1/mints/{mint_id}/uptime/timeseries +GET /v1/mints/by-url/uptime/timeseries?url= + +Query: +- window=24h | 7d | 30d +- bucket=5m | 15m | 1h + +Returns: +- timestamp +- state +- ok +- rtt_ms + +--- + +## Incidents + +GET /v1/mints/{mint_id}/incidents +GET /v1/mints/by-url/incidents?url= + +Returns: +- started_at +- resolved_at +- duration_seconds +- severity + +--- + +## Trust + +GET /v1/mints/{mint_id}/trust +GET /v1/mints/by-url/trust?url= + +Returns: +- score_total +- score_level +- breakdown +- computed_at + +--- + +## Reviews (Nostr) + +GET /v1/mints/{mint_id}/reviews +GET /v1/mints/by-url/reviews?url= + +Returns: +- event_id +- pubkey +- created_at +- rating +- content + +--- + +## Popularity + +GET /v1/mints/{mint_id}/views +GET /v1/mints/by-url/views?url= + +Returns: +- views_24h +- views_7d +- views_30d +- unique_sessions_30d +- view_velocity + +--- + +## Derived Features + +GET /v1/mints/{mint_id}/features +GET /v1/mints/by-url/features?url= + +Returns: +- supported_nuts +- supports_bolt11 +- min_amount +- max_amount +- has_tor_endpoint +- has_multiple_urls +- feature_completeness_score + +--- + +## Submission + +POST /v1/mints/submit + +Body: +- mint_url + +Returns: +- mint_id +- status +- message + +--- + +## Health & Stats + +GET /v1/health +GET /v1/stats + diff --git a/starter-docs/full_overview.md b/starter-docs/full_overview.md new file mode 100644 index 0000000..be7a700 --- /dev/null +++ b/starter-docs/full_overview.md @@ -0,0 +1,240 @@ +# Cashumints.space API – Full Overview + +## Purpose +The Cashumints.space API is a public, read‑only observability, discovery, and reputation API for the Cashu mint ecosystem. + +It exists to: +- Discover Cashu mints in a decentralized way +- Track availability, uptime, and incidents over time +- Fetch and keep mint metadata up to date using NUT‑06 +- Ingest and expose Nostr‑based mint reviews (NIP‑87) +- Compute transparent and explainable trust scores +- Provide rich analytics and time‑series data for frontends and wallets + +It explicitly does NOT: +- Custody funds +- Interact with wallets +- Simulate balances or volume +- Require authentication for read access + +The API is infrastructure, not a wallet. + +--- + +## Core Design Principles + +1. History is never erased +All raw data is stored and aggregated later. Nothing is overwritten without keeping history. + +2. Offline does not mean dead +Mints move through states: unknown β†’ online β†’ degraded β†’ offline β†’ abandoned. Recovery is always possible. + +3. URL is not identity +Mint identity is stable even if URLs change. One mint can have multiple URLs. + +4. Trust is derived, not asserted +Every trust signal is computed from stored data and exposed with a breakdown. + +5. Self‑hostable by default +SQLite is the default database. PostgreSQL is optional. No Redis or external queues. + +--- + +## High‑Level Architecture + +### Layers + +1. HTTP API +- Serves public read‑only endpoints +- Records pageviews +- Accepts mint submissions +- Enqueues background jobs + +2. Database +- SQLite by default +- PostgreSQL optional +- Single source of truth for: + - mints + - mint URLs + - probes + - metadata snapshots and history + - uptime rollups + - incidents + - reviews + - trust scores + - pageviews + - jobs + +3. Workers +- Stateless background processes +- Poll database for jobs +- Perform probes, aggregation, ingestion, scoring +- Never serve user traffic + +--- + +## Mint Identity Model + +### Mint +A mint is a logical entity identified internally by a stable `mint_id` (UUID). + +### Mint URLs +A mint may expose multiple URLs: +- clearnet +- Tor (.onion) +- mirrors + +URLs are aliases, not identities. + +Any known URL resolves to the same mint_id. + +--- + +## Mint Lifecycle + +### Discovery +Mints are discovered via: +- Nostr review events (NIP‑87) +- User submissions +- Manual/admin additions +- Imports + +Initial state: `unknown` + +### Verification +After the first successful probe: +- Status becomes `online` +- Uptime tracking begins +- Metadata fetching is enabled + +### Failure and Recovery +- Consecutive probe failures move the mint to `offline` +- Long‑term offline mints become `abandoned` +- A successful probe at any time revives the mint + +--- + +## Status Model + +Each mint is always in exactly one state: + +- unknown: never successfully probed +- online: recently reachable +- degraded: reachable but unstable or slow +- offline: previously online, now unreachable +- abandoned: offline longer than configured threshold + +--- + +## Probing and Uptime + +- Probes are HTTP requests to mint endpoints +- Every probe stores a result, success or failure +- Probe frequency adapts to mint status +- Raw probe data is aggregated into uptime rollups + +Rollup windows: +- 1 hour +- 24 hours +- 7 days +- 30 days + +Each rollup stores: +- uptime percentage +- average and percentile RTT +- downtime duration +- incident count + +--- + +## Metadata (NUT‑06) + +- Fetched from `/v1/info` +- Only fetched after a successful probe +- Fetched at most once per hour per mint +- Stored as a current snapshot +- All changes stored in history + +Metadata includes: +- name +- pubkey +- version +- descriptions +- contact info +- MOTD +- icon_url (stored as URL only) +- supported NUTs +- advertised URLs +- TOS URL + +--- + +## Nostr Reviews (NIP‑87) + +- Ingested continuously from configured relays +- Raw events stored immutably +- Reviews linked to mints via URLs or identifiers +- Ratings and content parsed when present + +Used for: +- Display +- Trust scoring +- Ecosystem analytics + +--- + +## Trust Score + +Each mint has a score from 0–100. + +Derived from: +- Uptime reliability +- Response speed +- Review quantity and quality +- Metadata completeness +- Penalties for downtime and instability + +Scores are: +- Computed asynchronously +- Stored with full breakdown +- Fully explainable + +--- + +## Pageviews and Popularity + +- Pageviews tracked per mint page +- Short‑lived session identifiers +- No raw IP storage + +Aggregated metrics: +- views_24h / 7d / 30d +- unique sessions +- view velocity +- trending detection + +Used as adoption proxies only. + +--- + +## Database Philosophy + +- Append raw data +- Derive aggregates +- Never delete history +- Always allow recomputation + +--- + +## Intended Consumers + +- Frontends (explorers, dashboards) +- Wallets (mint selection, warnings) +- Operators (transparency) + +--- + +## One‑Sentence Summary + +Cashumints.space is a decentralized, historical observability and reputation layer for the Cashu mint ecosystem. + diff --git a/starter-docs/logic.md b/starter-docs/logic.md new file mode 100644 index 0000000..0ad32d8 --- /dev/null +++ b/starter-docs/logic.md @@ -0,0 +1,207 @@ +# Cashumints.space API – Logic + +This document defines exactly how the system behaves internally. + +--- + +## URL Normalization + +All URLs are normalized before storage: +- force https if available +- lowercase hostname +- remove trailing slashes +- strip default ports + +Normalized URLs are used for identity resolution. + +--- + +## Mint Resolution + +If request uses mint_id: +- direct lookup + +If request uses URL: +- normalize +- lookup in mint_urls +- resolve mint_id + +If URL unknown: +- return 404 + +--- + +## Probing Logic + +### Probe Execution + +- HTTP GET to mint endpoints +- strict timeout +- record RTT +- record status code or error + +### State Transitions + +On success: +- last_success_at = now +- consecutive_failures = 0 +- status = online or degraded +- offline_since = null + +On failure: +- last_failure_at = now +- consecutive_failures += 1 +- if threshold reached: + - status = offline + - offline_since = now (if not set) + +If offline longer than ABANDONED_AFTER: +- status = abandoned + +--- + +## Metadata Fetch Logic + +- Only fetch metadata after successful probe +- Only fetch if last_fetched_at older than 1 hour + +Steps: +1. GET /v1/info +2. Validate against NUT‑06 +3. Normalize payload +4. Compute hash +5. If hash changed: + - store history record + - update snapshot + +Metadata is never deleted on failure. + +--- + +## URL Discovery + +URLs are discovered from: +- metadata.urls +- Nostr review references +- user submissions + +New URLs: +- linked to existing mint +- marked active + +Removed URLs: +- marked inactive +- never deleted + +--- + +## Uptime Rollups + +Raw probes are aggregated into windows: +- 1h, 24h, 7d, 30d + +Computed values: +- uptime_pct +- avg_rtt +- p95_rtt +- downtime_seconds +- incident count + +Rollups are recomputable at any time. + +--- + +## Incident Detection + +Incident starts when: +- status transitions online β†’ offline + +Incident ends when: +- first successful probe after offline + +Incident data: +- start +- end +- duration +- severity + +--- + +## Trust Score Calculation + +Score range: 0–100 + +Components: +- uptime (max 40) +- speed (max 25) +- reviews (max 20) +- identity (max 10) +- penalties (up to ‑15) + +Total score = sum, clamped. + +Scores are recomputed: +- after rollups +- after new reviews +- periodically + +--- + +## Review Aggregation + +- One review per pubkey per mint per window +- Raw reviews stored forever +- Aggregates computed separately + +--- + +## Pageviews + +- Recorded per mint page load +- Session‑based +- No IP storage + +Aggregated into: +- daily counts +- rolling windows + +--- + +## Job System + +Jobs stored in database: +- type +- payload +- run_at +- status +- retries + +Workers: +- poll jobs +- lock atomically +- execute +- retry with backoff + +--- + +## Failure Handling + +- All failures are recorded +- No silent drops +- History preserved + +--- + +## Determinism Guarantees + +- Same input data always produces same rollups +- Same rollups always produce same trust score +- System can be fully rebuilt from raw tables + +--- + +## Final Rule + +If data is not stored, it does not exist. +If it exists, it must be explainable. +