A community based topic aggregation platform built on atproto

feat: add production deployment infrastructure

- Docker configuration (Dockerfile, docker-compose.prod.yml)
- Caddy reverse proxy with HSTS, CSP, wildcard SSL
- Deployment scripts (deploy.sh, setup-production.sh, backup.sh)
- DID key generation script
- OAuth callback with XSS protection
- Environment template (.env.prod.example)

+1042
+57
.env.prod.example
··· 1 + # Coves Production Environment Variables 2 + # Copy to .env.prod and fill in real values 3 + # NEVER commit .env.prod to git! 4 + 5 + # ============================================================================= 6 + # PostgreSQL (AppView Database) 7 + # ============================================================================= 8 + POSTGRES_DB=coves_prod 9 + POSTGRES_USER=coves_user 10 + POSTGRES_PASSWORD=CHANGE_ME_SECURE_PASSWORD_HERE 11 + 12 + # ============================================================================= 13 + # PDS (Personal Data Server) 14 + # ============================================================================= 15 + # Generate with: openssl rand -hex 32 16 + PDS_JWT_SECRET=CHANGE_ME_64_HEX_CHARS 17 + 18 + # Admin password for PDS management 19 + PDS_ADMIN_PASSWORD=CHANGE_ME_SECURE_ADMIN_PASSWORD 20 + 21 + # K256 private key for DID rotation 22 + # Generate with: openssl rand -hex 32 23 + PDS_ROTATION_KEY=CHANGE_ME_64_HEX_CHARS 24 + 25 + # Optional: Email configuration for account recovery 26 + # PDS_EMAIL_SMTP_URL=smtp://user:pass@smtp.example.com:587 27 + # PDS_EMAIL_FROM_ADDRESS=noreply@coves.me 28 + 29 + # ============================================================================= 30 + # AppView OAuth (for mobile app authentication) 31 + # ============================================================================= 32 + OAUTH_CLIENT_ID=https://coves.social/client-metadata.json 33 + OAUTH_REDIRECT_URI=https://coves.social/oauth/callback 34 + 35 + # Generate EC P-256 private key in JWK format 36 + # See: https://atproto.com/specs/oauth#client-metadata 37 + OAUTH_PRIVATE_JWK={"kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."} 38 + 39 + # ============================================================================= 40 + # AppView Encryption 41 + # ============================================================================= 42 + # For encrypting community credentials in database 43 + # Generate with: openssl rand -base64 32 44 + ENCRYPTION_KEY=CHANGE_ME_BASE64_ENCODED_KEY 45 + 46 + # ============================================================================= 47 + # Cloudflare (for wildcard SSL certificates) 48 + # ============================================================================= 49 + # Required for *.coves.social wildcard certificate 50 + # Create at: Cloudflare Dashboard → My Profile → API Tokens → Create Token 51 + # Template: "Edit zone DNS" with permissions for coves.social zone 52 + CLOUDFLARE_API_TOKEN=CHANGE_ME_CLOUDFLARE_TOKEN 53 + 54 + # ============================================================================= 55 + # Optional: Versioning 56 + # ============================================================================= 57 + VERSION=latest
+1
.gitignore
··· 20 20 .env.local 21 21 .env.development 22 22 .env.production 23 + .env.prod 23 24 .env.test 24 25 25 26 # IDE
+139
Caddyfile
··· 1 + # Coves Production Caddyfile 2 + # Handles HTTPS for both coves.social (AppView) and coves.me (PDS) 3 + # 4 + # Domain architecture: 5 + # - coves.social: AppView (API, web app) 6 + # - *.coves.social: Community handles (route atproto-did to PDS) 7 + # - coves.me: PDS (data storage) 8 + 9 + # Community handle subdomains (e.g., gaming.coves.social) 10 + # These need to route /.well-known/atproto-did to PDS for handle resolution 11 + # 12 + # NOTE: Wildcard certs require DNS challenge. For Cloudflare: 13 + # 1. Create API token with Zone:DNS:Edit permissions 14 + # 2. Set CLOUDFLARE_API_TOKEN environment variable 15 + # 3. Use caddy-dns/cloudflare plugin (see docker-compose.prod.yml) 16 + *.coves.social { 17 + tls { 18 + dns cloudflare {env.CLOUDFLARE_API_TOKEN} 19 + } 20 + # Handle resolution - proxy to PDS 21 + handle /.well-known/atproto-did { 22 + reverse_proxy pds:3000 23 + } 24 + 25 + # OAuth well-known endpoints - proxy to PDS 26 + handle /.well-known/oauth-protected-resource { 27 + reverse_proxy pds:3000 28 + } 29 + 30 + handle /.well-known/oauth-authorization-server { 31 + reverse_proxy pds:3000 32 + } 33 + 34 + # All other requests return 404 (subdomains only exist for handle resolution) 35 + handle { 36 + respond "Not Found" 404 37 + } 38 + 39 + # Security headers 40 + header { 41 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 42 + X-Content-Type-Options "nosniff" 43 + -Server 44 + } 45 + } 46 + 47 + # AppView Domain (root) 48 + coves.social { 49 + # Serve .well-known files for DID verification 50 + handle /.well-known/* { 51 + root * /srv 52 + file_server 53 + } 54 + 55 + # Serve OAuth client metadata 56 + handle /client-metadata.json { 57 + root * /srv 58 + file_server 59 + } 60 + 61 + # Serve OAuth callback page 62 + handle /oauth/callback { 63 + root * /srv 64 + rewrite * /oauth/callback.html 65 + file_server 66 + } 67 + 68 + # Proxy all other requests to AppView 69 + handle { 70 + reverse_proxy appview:8080 { 71 + # Health check 72 + health_uri /xrpc/_health 73 + health_interval 30s 74 + health_timeout 5s 75 + 76 + # Headers 77 + header_up X-Real-IP {remote_host} 78 + header_up X-Forwarded-For {remote_host} 79 + header_up X-Forwarded-Proto {scheme} 80 + } 81 + } 82 + 83 + # Logging (Docker captures stdout/stderr) 84 + log { 85 + output stdout 86 + format json 87 + } 88 + 89 + # Security headers 90 + header { 91 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 92 + X-Content-Type-Options "nosniff" 93 + X-Frame-Options "DENY" 94 + Referrer-Policy "strict-origin-when-cross-origin" 95 + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.bsky.network wss://*.bsky.network" 96 + # Remove Server header 97 + -Server 98 + } 99 + 100 + # Enable compression 101 + encode gzip zstd 102 + } 103 + 104 + # PDS Domain 105 + coves.me { 106 + reverse_proxy pds:3000 { 107 + # Health check 108 + health_uri /xrpc/_health 109 + health_interval 30s 110 + health_timeout 5s 111 + 112 + # Headers for proper client IP handling 113 + header_up X-Real-IP {remote_host} 114 + header_up X-Forwarded-For {remote_host} 115 + header_up X-Forwarded-Proto {scheme} 116 + 117 + # WebSocket support for firehose 118 + header_up Connection {>Connection} 119 + header_up Upgrade {>Upgrade} 120 + } 121 + 122 + # Logging (Docker captures stdout/stderr) 123 + log { 124 + output stdout 125 + format json 126 + } 127 + 128 + # Security headers 129 + header { 130 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 131 + X-Content-Type-Options "nosniff" 132 + X-Frame-Options "DENY" 133 + Referrer-Policy "strict-origin-when-cross-origin" 134 + -Server 135 + } 136 + 137 + # Enable compression 138 + encode gzip zstd 139 + }
+61
Dockerfile
··· 1 + # Coves AppView - Multi-stage Dockerfile 2 + # Builds a minimal production image for the Go server 3 + 4 + # Stage 1: Build 5 + FROM golang:1.23-alpine AS builder 6 + 7 + # Install build dependencies 8 + RUN apk add --no-cache git ca-certificates tzdata 9 + 10 + # Set working directory 11 + WORKDIR /build 12 + 13 + # Copy go mod files first (better caching) 14 + COPY go.mod go.sum ./ 15 + RUN go mod download 16 + 17 + # Copy source code 18 + COPY . . 19 + 20 + # Build the binary 21 + # CGO_ENABLED=0 for static binary (no libc dependency) 22 + # -ldflags="-s -w" strips debug info for smaller binary 23 + RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ 24 + -ldflags="-s -w" \ 25 + -o /build/coves-server \ 26 + ./cmd/server 27 + 28 + # Stage 2: Runtime 29 + FROM alpine:3.19 30 + 31 + # Install runtime dependencies 32 + RUN apk add --no-cache ca-certificates tzdata 33 + 34 + # Create non-root user for security 35 + RUN addgroup -g 1000 coves && \ 36 + adduser -u 1000 -G coves -s /bin/sh -D coves 37 + 38 + # Set working directory 39 + WORKDIR /app 40 + 41 + # Copy binary from builder 42 + COPY --from=builder /build/coves-server /app/coves-server 43 + 44 + # Copy migrations (needed for goose) 45 + COPY --from=builder /build/internal/db/migrations /app/migrations 46 + 47 + # Set ownership 48 + RUN chown -R coves:coves /app 49 + 50 + # Switch to non-root user 51 + USER coves 52 + 53 + # Expose port 54 + EXPOSE 8080 55 + 56 + # Health check 57 + HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ 58 + CMD wget --spider -q http://localhost:8080/xrpc/_health || exit 1 59 + 60 + # Run the server 61 + ENTRYPOINT ["/app/coves-server"]
+205
docker-compose.prod.yml
··· 1 + # Coves Production Stack 2 + # 3 + # Architecture: 4 + # - coves.social: AppView domain (API, frontend, .well-known/did.json) 5 + # - coves.me: PDS domain (must be separate from AppView) 6 + # 7 + # Hardware: AMD Epyc 7351p (16c/32t), 256GB RAM, 2x500GB NVMe RAID 8 + # 9 + # Usage: 10 + # docker-compose -f docker-compose.prod.yml up -d 11 + # 12 + # Prerequisites: 13 + # 1. DNS configured for both domains 14 + # 2. SSL certificates (Caddy handles this automatically) 15 + # 3. .env.prod file with secrets 16 + # 4. .well-known/did.json deployed to coves.social 17 + 18 + services: 19 + # PostgreSQL Database for AppView 20 + postgres: 21 + image: postgres:15 22 + container_name: coves-prod-postgres 23 + restart: unless-stopped 24 + environment: 25 + POSTGRES_DB: ${POSTGRES_DB} 26 + POSTGRES_USER: ${POSTGRES_USER} 27 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 28 + volumes: 29 + - postgres-data:/var/lib/postgresql/data 30 + # Mount backup directory for pg_dump 31 + - ./backups:/backups 32 + networks: 33 + - coves-internal 34 + healthcheck: 35 + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 36 + interval: 10s 37 + timeout: 5s 38 + retries: 5 39 + # Generous limits for 256GB server 40 + deploy: 41 + resources: 42 + limits: 43 + memory: 32G 44 + reservations: 45 + memory: 4G 46 + 47 + # Coves AppView (Go Server) 48 + appview: 49 + build: 50 + context: . 51 + dockerfile: Dockerfile 52 + image: coves/appview:${VERSION:-latest} 53 + container_name: coves-prod-appview 54 + restart: unless-stopped 55 + ports: 56 + - "127.0.0.1:8080:8080" # Only expose to localhost (Caddy proxies) 57 + environment: 58 + # Database 59 + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable 60 + 61 + # Instance identity 62 + INSTANCE_DID: did:web:coves.social 63 + INSTANCE_DOMAIN: coves.social 64 + 65 + # PDS connection (separate domain!) 66 + PDS_URL: https://coves.me 67 + 68 + # Jetstream (Bluesky production firehose) 69 + JETSTREAM_URL: wss://jetstream2.us-east.bsky.network/subscribe 70 + 71 + # Security - MUST be false in production 72 + AUTH_SKIP_VERIFY: "false" 73 + SKIP_DID_WEB_VERIFICATION: "false" 74 + 75 + # OAuth (for community account provisioning) 76 + OAUTH_CLIENT_ID: ${OAUTH_CLIENT_ID} 77 + OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI} 78 + OAUTH_PRIVATE_JWK: ${OAUTH_PRIVATE_JWK} 79 + 80 + # Application settings 81 + PORT: 8080 82 + ENV: production 83 + LOG_LEVEL: info 84 + 85 + # Encryption key for community credentials 86 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} 87 + networks: 88 + - coves-internal 89 + depends_on: 90 + postgres: 91 + condition: service_healthy 92 + healthcheck: 93 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/xrpc/_health"] 94 + interval: 30s 95 + timeout: 5s 96 + retries: 3 97 + start_period: 10s 98 + # Go is memory-efficient, but give it room for connection pools 99 + deploy: 100 + resources: 101 + limits: 102 + memory: 8G 103 + reservations: 104 + memory: 512M 105 + 106 + # Bluesky PDS (Personal Data Server) 107 + # Handles community accounts and their repositories 108 + pds: 109 + image: ghcr.io/bluesky-social/pds:latest 110 + container_name: coves-prod-pds 111 + restart: unless-stopped 112 + ports: 113 + - "127.0.0.1:3000:3000" # Only expose to localhost (Caddy proxies) 114 + environment: 115 + # PDS identity 116 + PDS_HOSTNAME: coves.me 117 + PDS_PORT: 3000 118 + PDS_DATA_DIRECTORY: /pds 119 + PDS_BLOBSTORE_DISK_LOCATION: /pds/blocks 120 + 121 + # PLC Directory (production) 122 + PDS_DID_PLC_URL: https://plc.directory 123 + 124 + # Handle domains 125 + # Community handles use @community.coves.social (AppView domain) 126 + PDS_SERVICE_HANDLE_DOMAINS: .coves.social 127 + 128 + # Security (set real values in .env.prod) 129 + PDS_JWT_SECRET: ${PDS_JWT_SECRET} 130 + PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} 131 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${PDS_ROTATION_KEY} 132 + 133 + # Email (optional, for account recovery) 134 + PDS_EMAIL_SMTP_URL: ${PDS_EMAIL_SMTP_URL:-} 135 + PDS_EMAIL_FROM_ADDRESS: ${PDS_EMAIL_FROM_ADDRESS:-noreply@coves.me} 136 + 137 + # Production mode 138 + PDS_DEV_MODE: "false" 139 + PDS_INVITE_REQUIRED: "false" # Set to true if you want invite-only 140 + 141 + # Logging 142 + NODE_ENV: production 143 + LOG_ENABLED: "true" 144 + LOG_LEVEL: info 145 + volumes: 146 + - pds-data:/pds 147 + networks: 148 + - coves-internal 149 + healthcheck: 150 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/xrpc/_health"] 151 + interval: 30s 152 + timeout: 5s 153 + retries: 5 154 + # PDS (Node.js) needs memory for blob handling 155 + deploy: 156 + resources: 157 + limits: 158 + memory: 16G 159 + reservations: 160 + memory: 1G 161 + 162 + # Caddy Reverse Proxy 163 + # Handles HTTPS automatically via Let's Encrypt 164 + # Uses Cloudflare plugin for wildcard SSL certificates (*.coves.social) 165 + caddy: 166 + # Pre-built Caddy with Cloudflare DNS plugin 167 + # Updates automatically with docker-compose pull 168 + # Alternative: build your own with Dockerfile.caddy 169 + image: ghcr.io/slothcroissant/caddy-cloudflaredns:latest 170 + container_name: coves-prod-caddy 171 + restart: unless-stopped 172 + ports: 173 + - "80:80" 174 + - "443:443" 175 + environment: 176 + # Required for wildcard SSL via DNS challenge 177 + # Create at: Cloudflare Dashboard → My Profile → API Tokens → Create Token 178 + # Permissions: Zone:DNS:Edit for coves.social zone 179 + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} 180 + volumes: 181 + - ./Caddyfile:/etc/caddy/Caddyfile:ro 182 + - caddy-data:/data 183 + - caddy-config:/config 184 + # Static files (.well-known, client-metadata.json, oauth callback) 185 + - ./static:/srv:ro 186 + networks: 187 + - coves-internal 188 + depends_on: 189 + - appview 190 + - pds 191 + 192 + networks: 193 + coves-internal: 194 + driver: bridge 195 + name: coves-prod-network 196 + 197 + volumes: 198 + postgres-data: 199 + name: coves-prod-postgres-data 200 + pds-data: 201 + name: coves-prod-pds-data 202 + caddy-data: 203 + name: coves-prod-caddy-data 204 + caddy-config: 205 + name: coves-prod-caddy-config
+57
scripts/backup.sh
··· 1 + #!/bin/bash 2 + # Coves Database Backup Script 3 + # Usage: ./scripts/backup.sh 4 + # 5 + # Creates timestamped PostgreSQL backups in ./backups/ 6 + # Retention: Keeps last 30 days of backups 7 + 8 + set -e 9 + 10 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 11 + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 12 + BACKUP_DIR="$PROJECT_DIR/backups" 13 + COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml" 14 + 15 + # Load environment 16 + set -a 17 + source "$PROJECT_DIR/.env.prod" 18 + set +a 19 + 20 + # Colors 21 + GREEN='\033[0;32m' 22 + YELLOW='\033[1;33m' 23 + NC='\033[0m' 24 + 25 + log() { echo -e "${GREEN}[BACKUP]${NC} $1"; } 26 + warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 27 + 28 + # Create backup directory 29 + mkdir -p "$BACKUP_DIR" 30 + 31 + # Generate timestamp 32 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) 33 + BACKUP_FILE="$BACKUP_DIR/coves_${TIMESTAMP}.sql.gz" 34 + 35 + log "Starting backup..." 36 + 37 + # Run pg_dump inside container 38 + docker compose -f "$COMPOSE_FILE" exec -T postgres \ 39 + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \ 40 + | gzip > "$BACKUP_FILE" 41 + 42 + # Get file size 43 + SIZE=$(du -h "$BACKUP_FILE" | cut -f1) 44 + 45 + log "✅ Backup complete: $BACKUP_FILE ($SIZE)" 46 + 47 + # Cleanup old backups (keep last 30 days) 48 + log "Cleaning up backups older than 30 days..." 49 + find "$BACKUP_DIR" -name "coves_*.sql.gz" -mtime +30 -delete 50 + 51 + # List recent backups 52 + log "" 53 + log "Recent backups:" 54 + ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -5 55 + 56 + log "" 57 + log "To restore: gunzip -c $BACKUP_FILE | docker compose -f docker-compose.prod.yml exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB"
+133
scripts/deploy.sh
··· 1 + #!/bin/bash 2 + # Coves Deployment Script 3 + # Usage: ./scripts/deploy.sh [service] 4 + # 5 + # Examples: 6 + # ./scripts/deploy.sh # Deploy all services 7 + # ./scripts/deploy.sh appview # Deploy only AppView 8 + # ./scripts/deploy.sh --pull # Pull from git first, then deploy 9 + 10 + set -e 11 + 12 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 13 + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 14 + COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml" 15 + 16 + # Colors for output 17 + RED='\033[0;31m' 18 + GREEN='\033[0;32m' 19 + YELLOW='\033[1;33m' 20 + NC='\033[0m' # No Color 21 + 22 + log() { 23 + echo -e "${GREEN}[DEPLOY]${NC} $1" 24 + } 25 + 26 + warn() { 27 + echo -e "${YELLOW}[WARN]${NC} $1" 28 + } 29 + 30 + error() { 31 + echo -e "${RED}[ERROR]${NC} $1" 32 + exit 1 33 + } 34 + 35 + # Parse arguments 36 + PULL_GIT=false 37 + SERVICE="" 38 + 39 + for arg in "$@"; do 40 + case $arg in 41 + --pull) 42 + PULL_GIT=true 43 + ;; 44 + *) 45 + SERVICE="$arg" 46 + ;; 47 + esac 48 + done 49 + 50 + cd "$PROJECT_DIR" 51 + 52 + # Load environment variables 53 + if [ ! -f ".env.prod" ]; then 54 + error ".env.prod not found! Copy from .env.prod.example and configure secrets." 55 + fi 56 + 57 + log "Loading environment from .env.prod..." 58 + set -a 59 + source .env.prod 60 + set +a 61 + 62 + # Optional: Pull from git 63 + if [ "$PULL_GIT" = true ]; then 64 + log "Pulling latest code from git..." 65 + git fetch origin 66 + git pull origin main 67 + fi 68 + 69 + # Check database connectivity before deployment 70 + log "Checking database connectivity..." 71 + if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then 72 + log "Database is ready" 73 + else 74 + warn "Database not ready yet - it will start with the deployment" 75 + fi 76 + 77 + # Build and deploy 78 + if [ -n "$SERVICE" ]; then 79 + log "Building $SERVICE..." 80 + docker compose -f "$COMPOSE_FILE" build --no-cache "$SERVICE" 81 + 82 + log "Deploying $SERVICE..." 83 + docker compose -f "$COMPOSE_FILE" up -d "$SERVICE" 84 + else 85 + log "Building all services..." 86 + docker compose -f "$COMPOSE_FILE" build --no-cache 87 + 88 + log "Deploying all services..." 89 + docker compose -f "$COMPOSE_FILE" up -d 90 + fi 91 + 92 + # Health check 93 + log "Waiting for services to be healthy..." 94 + sleep 10 95 + 96 + # Wait for database to be ready before running migrations 97 + log "Waiting for database..." 98 + for i in {1..30}; do 99 + if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then 100 + break 101 + fi 102 + sleep 1 103 + done 104 + 105 + # Run database migrations 106 + # The AppView runs migrations on startup, but we can also trigger them explicitly 107 + log "Running database migrations..." 108 + if docker compose -f "$COMPOSE_FILE" exec -T appview /app/coves-server migrate 2>/dev/null; then 109 + log "✅ Migrations completed" 110 + else 111 + warn "⚠️ Migration command not available or failed - AppView will run migrations on startup" 112 + fi 113 + 114 + # Check AppView health 115 + if docker compose -f "$COMPOSE_FILE" exec -T appview wget --spider -q http://localhost:8080/xrpc/_health 2>/dev/null; then 116 + log "✅ AppView is healthy" 117 + else 118 + warn "⚠️ AppView health check failed - check logs with: docker compose -f docker-compose.prod.yml logs appview" 119 + fi 120 + 121 + # Check PDS health 122 + if docker compose -f "$COMPOSE_FILE" exec -T pds wget --spider -q http://localhost:3000/xrpc/_health 2>/dev/null; then 123 + log "✅ PDS is healthy" 124 + else 125 + warn "⚠️ PDS health check failed - check logs with: docker compose -f docker-compose.prod.yml logs pds" 126 + fi 127 + 128 + log "Deployment complete!" 129 + log "" 130 + log "Useful commands:" 131 + log " View logs: docker compose -f docker-compose.prod.yml logs -f" 132 + log " Check status: docker compose -f docker-compose.prod.yml ps" 133 + log " Rollback: docker compose -f docker-compose.prod.yml down && git checkout HEAD~1 && ./scripts/deploy.sh"
+149
scripts/generate-did-keys.sh
··· 1 + #!/bin/bash 2 + # Generate cryptographic keys for Coves did:web DID document 3 + # 4 + # This script generates a secp256k1 (K-256) key pair as required by atproto. 5 + # Reference: https://atproto.com/specs/cryptography 6 + # 7 + # Key format: 8 + # - Curve: secp256k1 (K-256) - same as Bitcoin/Ethereum 9 + # - Type: Multikey 10 + # - Encoding: publicKeyMultibase with base58btc ('z' prefix) 11 + # - Multicodec: 0xe7 for secp256k1 compressed public key 12 + # 13 + # Output: 14 + # - Private key (hex) for PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX 15 + # - Public key (multibase) for did.json publicKeyMultibase field 16 + # - Complete did.json file 17 + 18 + set -e 19 + 20 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 21 + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 22 + OUTPUT_DIR="$PROJECT_DIR/static/.well-known" 23 + 24 + # Colors 25 + GREEN='\033[0;32m' 26 + YELLOW='\033[1;33m' 27 + RED='\033[0;31m' 28 + NC='\033[0m' 29 + 30 + log() { echo -e "${GREEN}[KEYGEN]${NC} $1"; } 31 + warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 32 + error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } 33 + 34 + # Check for required tools 35 + if ! command -v openssl &> /dev/null; then 36 + error "openssl is required but not installed" 37 + fi 38 + 39 + if ! command -v python3 &> /dev/null; then 40 + error "python3 is required for base58 encoding" 41 + fi 42 + 43 + # Check for base58 library 44 + if ! python3 -c "import base58" 2>/dev/null; then 45 + warn "Installing base58 Python library..." 46 + pip3 install base58 || error "Failed to install base58. Run: pip3 install base58" 47 + fi 48 + 49 + log "Generating secp256k1 key pair for did:web..." 50 + 51 + # Generate private key 52 + PRIVATE_KEY_PEM=$(mktemp) 53 + openssl ecparam -name secp256k1 -genkey -noout -out "$PRIVATE_KEY_PEM" 2>/dev/null 54 + 55 + # Extract private key as hex (for PDS config) 56 + PRIVATE_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -text -noout 2>/dev/null | \ 57 + grep -A 3 "priv:" | tail -n 3 | tr -d ' :\n' | tr -d '\r') 58 + 59 + # Extract public key as compressed format 60 + # OpenSSL outputs the public key, we need to get the compressed form 61 + PUBLIC_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -pubout -conv_form compressed -outform DER 2>/dev/null | \ 62 + tail -c 33 | xxd -p | tr -d '\n') 63 + 64 + # Clean up temp file 65 + rm -f "$PRIVATE_KEY_PEM" 66 + 67 + # Encode public key as multibase with multicodec 68 + # Multicodec 0xe7 = secp256k1 compressed public key 69 + # Then base58btc encode with 'z' prefix 70 + PUBLIC_KEY_MULTIBASE=$(python3 << EOF 71 + import base58 72 + 73 + # Compressed public key bytes 74 + pub_hex = "$PUBLIC_KEY_HEX" 75 + pub_bytes = bytes.fromhex(pub_hex) 76 + 77 + # Prepend multicodec 0xe7 for secp256k1-pub 78 + # 0xe7 as varint is just 0xe7 (single byte, < 128) 79 + multicodec = bytes([0xe7, 0x01]) # 0xe701 for secp256k1-pub compressed 80 + key_with_codec = multicodec + pub_bytes 81 + 82 + # Base58btc encode 83 + encoded = base58.b58encode(key_with_codec).decode('ascii') 84 + 85 + # Add 'z' prefix for multibase 86 + print('z' + encoded) 87 + EOF 88 + ) 89 + 90 + log "Keys generated successfully!" 91 + echo "" 92 + echo "============================================" 93 + echo " PRIVATE KEY (keep secret!)" 94 + echo "============================================" 95 + echo "" 96 + echo "Add this to your .env.prod file:" 97 + echo "" 98 + echo "PDS_ROTATION_KEY=$PRIVATE_KEY_HEX" 99 + echo "" 100 + echo "============================================" 101 + echo " PUBLIC KEY (for did.json)" 102 + echo "============================================" 103 + echo "" 104 + echo "publicKeyMultibase: $PUBLIC_KEY_MULTIBASE" 105 + echo "" 106 + 107 + # Generate the did.json file 108 + log "Generating did.json..." 109 + 110 + mkdir -p "$OUTPUT_DIR" 111 + 112 + cat > "$OUTPUT_DIR/did.json" << EOF 113 + { 114 + "id": "did:web:coves.social", 115 + "alsoKnownAs": ["at://coves.social"], 116 + "verificationMethod": [ 117 + { 118 + "id": "did:web:coves.social#atproto", 119 + "type": "Multikey", 120 + "controller": "did:web:coves.social", 121 + "publicKeyMultibase": "$PUBLIC_KEY_MULTIBASE" 122 + } 123 + ], 124 + "service": [ 125 + { 126 + "id": "#atproto_pds", 127 + "type": "AtprotoPersonalDataServer", 128 + "serviceEndpoint": "https://coves.me" 129 + } 130 + ] 131 + } 132 + EOF 133 + 134 + log "Created: $OUTPUT_DIR/did.json" 135 + echo "" 136 + echo "============================================" 137 + echo " NEXT STEPS" 138 + echo "============================================" 139 + echo "" 140 + echo "1. Copy the PDS_ROTATION_KEY value to your .env.prod file" 141 + echo "" 142 + echo "2. Verify the did.json looks correct:" 143 + echo " cat $OUTPUT_DIR/did.json" 144 + echo "" 145 + echo "3. After deployment, verify it's accessible:" 146 + echo " curl https://coves.social/.well-known/did.json" 147 + echo "" 148 + warn "IMPORTANT: Keep the private key secret! Only share the public key." 149 + warn "The did.json file with the public key IS safe to commit to git."
+106
scripts/setup-production.sh
··· 1 + #!/bin/bash 2 + # Coves Production Setup Script 3 + # Run this once on a fresh server to set up everything 4 + # 5 + # Prerequisites: 6 + # - Docker and docker-compose installed 7 + # - Git installed 8 + # - .env.prod file configured 9 + 10 + set -e 11 + 12 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 13 + PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 14 + 15 + # Colors 16 + GREEN='\033[0;32m' 17 + YELLOW='\033[1;33m' 18 + RED='\033[0;31m' 19 + NC='\033[0m' 20 + 21 + log() { echo -e "${GREEN}[SETUP]${NC} $1"; } 22 + warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 23 + error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } 24 + 25 + cd "$PROJECT_DIR" 26 + 27 + # Check prerequisites 28 + log "Checking prerequisites..." 29 + 30 + if ! command -v docker &> /dev/null; then 31 + error "Docker is not installed. Install with: curl -fsSL https://get.docker.com | sh" 32 + fi 33 + 34 + if ! docker compose version &> /dev/null; then 35 + error "docker compose is not available. Install with: apt install docker-compose-plugin" 36 + fi 37 + 38 + # Check for .env.prod 39 + if [ ! -f ".env.prod" ]; then 40 + error ".env.prod not found! Copy from .env.prod.example and configure secrets." 41 + fi 42 + 43 + # Load environment 44 + set -a 45 + source .env.prod 46 + set +a 47 + 48 + # Create required directories 49 + log "Creating directories..." 50 + mkdir -p backups 51 + mkdir -p static/.well-known 52 + 53 + # Check for did.json 54 + if [ ! -f "static/.well-known/did.json" ]; then 55 + warn "static/.well-known/did.json not found!" 56 + warn "Run ./scripts/generate-did-keys.sh to create it." 57 + fi 58 + 59 + # Note: Caddy logs are written to Docker volume (caddy-data) 60 + # If you need host-accessible logs, uncomment and run as root: 61 + # mkdir -p /var/log/caddy && chown 1000:1000 /var/log/caddy 62 + 63 + # Pull Docker images 64 + log "Pulling Docker images..." 65 + docker compose -f docker-compose.prod.yml pull postgres pds caddy 66 + 67 + # Build AppView 68 + log "Building AppView..." 69 + docker compose -f docker-compose.prod.yml build appview 70 + 71 + # Start services 72 + log "Starting services..." 73 + docker compose -f docker-compose.prod.yml up -d 74 + 75 + # Wait for PostgreSQL 76 + log "Waiting for PostgreSQL to be ready..." 77 + until docker compose -f docker-compose.prod.yml exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do 78 + sleep 2 79 + done 80 + log "PostgreSQL is ready!" 81 + 82 + # Run migrations 83 + log "Running database migrations..." 84 + # The AppView runs migrations on startup, but you can also run them manually: 85 + # docker compose -f docker-compose.prod.yml exec appview /app/coves-server migrate 86 + 87 + # Final status 88 + log "" 89 + log "============================================" 90 + log " Coves Production Setup Complete!" 91 + log "============================================" 92 + log "" 93 + log "Services running:" 94 + docker compose -f docker-compose.prod.yml ps 95 + log "" 96 + log "Next steps:" 97 + log " 1. Configure DNS for coves.social and coves.me" 98 + log " 2. Run ./scripts/generate-did-keys.sh to create DID keys" 99 + log " 3. Test health endpoints:" 100 + log " curl https://coves.social/xrpc/_health" 101 + log " curl https://coves.me/xrpc/_health" 102 + log "" 103 + log "Useful commands:" 104 + log " View logs: docker compose -f docker-compose.prod.yml logs -f" 105 + log " Deploy update: ./scripts/deploy.sh appview" 106 + log " Backup DB: ./scripts/backup.sh"
+19
static/.well-known/did.json.template
··· 1 + { 2 + "id": "did:web:coves.social", 3 + "alsoKnownAs": ["at://coves.social"], 4 + "verificationMethod": [ 5 + { 6 + "id": "did:web:coves.social#atproto", 7 + "type": "Multikey", 8 + "controller": "did:web:coves.social", 9 + "publicKeyMultibase": "REPLACE_WITH_YOUR_PUBLIC_KEY" 10 + } 11 + ], 12 + "service": [ 13 + { 14 + "id": "#atproto_pds", 15 + "type": "AtprotoPersonalDataServer", 16 + "serviceEndpoint": "https://coves.me" 17 + } 18 + ] 19 + }
+18
static/client-metadata.json
··· 1 + { 2 + "client_id": "https://coves.social/client-metadata.json", 3 + "client_name": "Coves", 4 + "client_uri": "https://coves.social", 5 + "logo_uri": "https://coves.social/logo.png", 6 + "tos_uri": "https://coves.social/terms", 7 + "policy_uri": "https://coves.social/privacy", 8 + "redirect_uris": [ 9 + "https://coves.social/oauth/callback", 10 + "social.coves:/oauth/callback" 11 + ], 12 + "scope": "atproto transition:generic", 13 + "grant_types": ["authorization_code", "refresh_token"], 14 + "response_types": ["code"], 15 + "application_type": "native", 16 + "token_endpoint_auth_method": "none", 17 + "dpop_bound_access_tokens": true 18 + }
+97
static/oauth/callback.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"> 7 + <title>Authorization Successful - Coves</title> 8 + <style> 9 + body { 10 + font-family: system-ui, -apple-system, sans-serif; 11 + display: flex; 12 + align-items: center; 13 + justify-content: center; 14 + min-height: 100vh; 15 + margin: 0; 16 + background: #f5f5f5; 17 + } 18 + .container { 19 + text-align: center; 20 + padding: 2rem; 21 + background: white; 22 + border-radius: 8px; 23 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 24 + max-width: 400px; 25 + } 26 + .success { color: #22c55e; font-size: 3rem; margin-bottom: 1rem; } 27 + h1 { margin: 0 0 0.5rem; color: #1f2937; font-size: 1.5rem; } 28 + p { color: #6b7280; margin: 0.5rem 0; } 29 + a { 30 + display: inline-block; 31 + margin-top: 1rem; 32 + padding: 0.75rem 1.5rem; 33 + background: #3b82f6; 34 + color: white; 35 + text-decoration: none; 36 + border-radius: 6px; 37 + font-weight: 500; 38 + } 39 + a:hover { background: #2563eb; } 40 + </style> 41 + </head> 42 + <body> 43 + <div class="container"> 44 + <div class="success">✓</div> 45 + <h1>Authorization Successful!</h1> 46 + <p id="status">Returning to Coves...</p> 47 + <a href="#" id="manualLink">Open Coves</a> 48 + </div> 49 + <script> 50 + (function() { 51 + // Parse and sanitize query params - only allow expected OAuth parameters 52 + const urlParams = new URLSearchParams(window.location.search); 53 + const safeParams = new URLSearchParams(); 54 + 55 + // Whitelist only expected OAuth callback parameters 56 + const code = urlParams.get('code'); 57 + const state = urlParams.get('state'); 58 + const error = urlParams.get('error'); 59 + const errorDescription = urlParams.get('error_description'); 60 + const iss = urlParams.get('iss'); 61 + 62 + if (code) safeParams.set('code', code); 63 + if (state) safeParams.set('state', state); 64 + if (error) safeParams.set('error', error); 65 + if (errorDescription) safeParams.set('error_description', errorDescription); 66 + if (iss) safeParams.set('iss', iss); 67 + 68 + const sanitizedQuery = safeParams.toString() ? '?' + safeParams.toString() : ''; 69 + 70 + const userAgent = navigator.userAgent || ''; 71 + const isAndroid = /Android/i.test(userAgent); 72 + 73 + // Build deep link based on platform 74 + let deepLink; 75 + if (isAndroid) { 76 + // Android: Intent URL format 77 + const pathAndQuery = '/oauth/callback' + sanitizedQuery; 78 + deepLink = 'intent:/' + pathAndQuery + '#Intent;scheme=social.coves;package=social.coves;end'; 79 + } else { 80 + // iOS: Custom scheme 81 + deepLink = 'social.coves:/oauth/callback' + sanitizedQuery; 82 + } 83 + 84 + // Update manual link 85 + document.getElementById('manualLink').href = deepLink; 86 + 87 + // Attempt automatic redirect 88 + window.location.href = deepLink; 89 + 90 + // Update status after 2 seconds if redirect didn't work 91 + setTimeout(function() { 92 + document.getElementById('status').textContent = 'Click the button above to continue'; 93 + }, 2000); 94 + })(); 95 + </script> 96 + </body> 97 + </html>