Initial commit

This commit is contained in:
Michilis
2025-12-19 23:56:07 -03:00
commit 23f716255e
48 changed files with 14834 additions and 0 deletions

94
.gitignore vendored Normal file
View 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
View 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)

View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

290
src/index.js Normal file
View 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 };

View 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();
};
}

View 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 })
});
}

View 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
View 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
View 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
View 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;

View 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()
};
}

View 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
View 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 });
}
}
}

View 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
View 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'
};
}

View 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',
}));
}

View 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;
}
}

View 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;
}

View 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
}));
}

View 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
};
});
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };

View 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
View File

@@ -0,0 +1,229 @@
# Cashumints.space API Endpoints
Base path:
/v1
All endpoints are readonly unless stated otherwise.
All timestamps are ISO8601 UTC.
All mintspecific 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 (NUT06)
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

View File

@@ -0,0 +1,240 @@
# Cashumints.space API Full Overview
## Purpose
The Cashumints.space API is a public, readonly 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 NUT06
- Ingest and expose Nostrbased mint reviews (NIP87)
- Compute transparent and explainable trust scores
- Provide rich analytics and timeseries 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. Selfhostable by default
SQLite is the default database. PostgreSQL is optional. No Redis or external queues.
---
## HighLevel Architecture
### Layers
1. HTTP API
- Serves public readonly 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 (NIP87)
- 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`
- Longterm 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 (NUT06)
- 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 (NIP87)
- 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 0100.
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
- Shortlived 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)
---
## OneSentence Summary
Cashumints.space is a decentralized, historical observability and reputation layer for the Cashu mint ecosystem.

207
starter-docs/logic.md Normal file
View 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 NUT06
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: 0100
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
- Sessionbased
- 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.