Initial commit
This commit is contained in:
94
.gitignore
vendored
Normal file
94
.gitignore
vendored
Normal file
@@ -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
|
||||||
418
README.md
Normal file
418
README.md
Normal file
@@ -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 <repo>
|
||||||
|
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 <repo> /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)
|
||||||
64
deploy/cashumints-api.service
Normal file
64
deploy/cashumints-api.service
Normal file
@@ -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
|
||||||
|
|
||||||
63
deploy/cashumints-workers.service
Normal file
63
deploy/cashumints-workers.service
Normal file
@@ -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
|
||||||
|
|
||||||
176
deploy/install.sh
Normal file
176
deploy/install.sh
Normal file
@@ -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 ""
|
||||||
|
|
||||||
160
deploy/nginx.conf
Normal file
160
deploy/nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
139
env.example
Normal file
139
env.example
Normal file
@@ -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
|
||||||
|
#
|
||||||
1675
package-lock.json
generated
Normal file
1675
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/config.js
Normal file
91
src/config.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
154
src/db/connection.js
Normal file
154
src/db/connection.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
21
src/db/migrate.js
Normal file
21
src/db/migrate.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
59
src/db/schema-admin.sql
Normal file
59
src/db/schema-admin.sql
Normal file
@@ -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
|
||||||
|
|
||||||
271
src/db/schema.sql
Normal file
271
src/db/schema.sql
Normal file
@@ -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);
|
||||||
|
|
||||||
2300
src/docs/openapi.js
Normal file
2300
src/docs/openapi.js
Normal file
File diff suppressed because it is too large
Load Diff
290
src/index.js
Normal file
290
src/index.js
Normal file
@@ -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 };
|
||||||
97
src/middleware/adminAuth.js
Normal file
97
src/middleware/adminAuth.js
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/middleware/errorHandler.js
Normal file
43
src/middleware/errorHandler.js
Normal file
@@ -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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
77
src/middleware/rateLimit.js
Normal file
77
src/middleware/rateLimit.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
597
src/routes/admin.js
Normal file
597
src/routes/admin.js
Normal file
@@ -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;
|
||||||
909
src/routes/mints.js
Normal file
909
src/routes/mints.js
Normal file
@@ -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;
|
||||||
362
src/routes/system.js
Normal file
362
src/routes/system.js
Normal file
@@ -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;
|
||||||
751
src/services/AdminService.js
Normal file
751
src/services/AdminService.js
Normal file
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
788
src/services/AnalyticsService.js
Normal file
788
src/services/AnalyticsService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
209
src/services/JobService.js
Normal file
209
src/services/JobService.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
358
src/services/MetadataService.js
Normal file
358
src/services/MetadataService.js
Normal file
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
284
src/services/MintService.js
Normal file
284
src/services/MintService.js
Normal file
@@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
325
src/services/PageviewService.js
Normal file
325
src/services/PageviewService.js
Normal file
@@ -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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
413
src/services/PlausibleService.js
Normal file
413
src/services/PlausibleService.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
297
src/services/ProbeService.js
Normal file
297
src/services/ProbeService.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
460
src/services/ReviewService.js
Normal file
460
src/services/ReviewService.js
Normal file
@@ -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", "<mint_url>"], ["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
|
||||||
|
}));
|
||||||
|
}
|
||||||
462
src/services/TrustService.js
Normal file
462
src/services/TrustService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
284
src/services/UptimeService.js
Normal file
284
src/services/UptimeService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
15
src/services/index.js
Normal file
15
src/services/index.js
Normal file
@@ -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';
|
||||||
51
src/utils/crypto.js
Normal file
51
src/utils/crypto.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
10
src/utils/index.js
Normal file
10
src/utils/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Utility Exports
|
||||||
|
*
|
||||||
|
* Central export point for all utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './url.js';
|
||||||
|
export * from './crypto.js';
|
||||||
|
export * from './time.js';
|
||||||
|
|
||||||
124
src/utils/time.js
Normal file
124
src/utils/time.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
176
src/utils/url.js
Normal file
176
src/utils/url.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
98
src/workers/index.js
Normal file
98
src/workers/index.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
102
src/workers/metadata.js
Normal file
102
src/workers/metadata.js
Normal file
@@ -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 };
|
||||||
333
src/workers/nostr.js
Normal file
333
src/workers/nostr.js
Normal file
@@ -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 };
|
||||||
74
src/workers/probe.js
Normal file
74
src/workers/probe.js
Normal file
@@ -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 };
|
||||||
54
src/workers/rollup.js
Normal file
54
src/workers/rollup.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
52
src/workers/trust.js
Normal file
52
src/workers/trust.js
Normal file
@@ -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 };
|
||||||
|
|
||||||
327
starter-docs/admin_endpoints.md
Normal file
327
starter-docs/admin_endpoints.md
Normal file
@@ -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.
|
||||||
229
starter-docs/endpoints.md
Normal file
229
starter-docs/endpoints.md
Normal file
@@ -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
|
||||||
|
|
||||||
240
starter-docs/full_overview.md
Normal file
240
starter-docs/full_overview.md
Normal file
@@ -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.
|
||||||
|
|
||||||
207
starter-docs/logic.md
Normal file
207
starter-docs/logic.md
Normal file
@@ -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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user