I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go

Quick fixes and debugging

-14
.sqlx/query-076cbf7f32c5f0103207a8e0e73dd5768681ff2520682edda8f2977dcae7cd62.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity')", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "076cbf7f32c5f0103207a8e0e73dd5768681ff2520682edda8f2977dcae7cd62" 14 - }
-28
.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT handle, email FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "handle", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "email", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a" 28 - }
+70
.sqlx/query-3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT\n handle, email, email_confirmed,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_confirmed", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "preferred_channel: crate::notifications::NotificationChannel", 24 + "type_info": { 25 + "Custom": { 26 + "name": "notification_channel", 27 + "kind": { 28 + "Enum": [ 29 + "email", 30 + "discord", 31 + "telegram", 32 + "signal" 33 + ] 34 + } 35 + } 36 + } 37 + }, 38 + { 39 + "ordinal": 4, 40 + "name": "discord_verified", 41 + "type_info": "Bool" 42 + }, 43 + { 44 + "ordinal": 5, 45 + "name": "telegram_verified", 46 + "type_info": "Bool" 47 + }, 48 + { 49 + "ordinal": 6, 50 + "name": "signal_verified", 51 + "type_info": "Bool" 52 + } 53 + ], 54 + "parameters": { 55 + "Left": [ 56 + "Text" 57 + ] 58 + }, 59 + "nullable": [ 60 + false, 61 + true, 62 + false, 63 + false, 64 + false, 65 + false, 66 + false 67 + ] 68 + }, 69 + "hash": "3727afe601beffcb4551c23eb59fa1c25ca59150c90d4866050b3a073bbe7b4b" 70 + }
+22
.sqlx/query-95165c49f57bb8e130bf1d8444566e1f9521777f994573a4f3cdee809fb63fd7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity') RETURNING seq", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "seq", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "95165c49f57bb8e130bf1d8444566e1f9521777f994573a4f3cdee809fb63fd7" 22 + }
-19
.sqlx/query-aadc1f8c79d79e9a32fe6f4bf7e901076532fa2bf8f0b4d0f1bae7aa0f792183.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - "Text", 11 - "Jsonb", 12 - "TextArray", 13 - "TextArray" 14 - ] 15 - }, 16 - "nullable": [] 17 - }, 18 - "hash": "aadc1f8c79d79e9a32fe6f4bf7e901076532fa2bf8f0b4d0f1bae7aa0f792183" 19 - }
+12 -6
.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json .sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 + "name": "email", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 23 28 "name": "email_confirmation_code", 24 29 "type_info": "Text" 25 30 }, 26 31 { 27 - "ordinal": 4, 32 + "ordinal": 5, 28 33 "name": "email_confirmation_code_expires_at", 29 34 "type_info": "Timestamptz" 30 35 }, 31 36 { 32 - "ordinal": 5, 37 + "ordinal": 6, 33 38 "name": "channel: crate::notifications::NotificationChannel", 34 39 "type_info": { 35 40 "Custom": { ··· 46 51 } 47 52 }, 48 53 { 49 - "ordinal": 6, 54 + "ordinal": 7, 50 55 "name": "key_bytes", 51 56 "type_info": "Bytea" 52 57 }, 53 58 { 54 - "ordinal": 7, 59 + "ordinal": 8, 55 60 "name": "encryption_version", 56 61 "type_info": "Int4" 57 62 } ··· 67 72 false, 68 73 true, 69 74 true, 75 + true, 70 76 false, 71 77 false, 72 78 true 73 79 ] 74 80 }, 75 - "hash": "cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190" 81 + "hash": "257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b" 76 82 }
+27
.sqlx/query-f68a05d2c78cc060b43c81b177a24f89c71e7e00dfa5af08ff4584bbe43b4155.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $3, $4, $5, $6)\n RETURNING seq\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "seq", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text", 16 + "Text", 17 + "Jsonb", 18 + "TextArray", 19 + "TextArray" 20 + ] 21 + }, 22 + "nullable": [ 23 + false 24 + ] 25 + }, 26 + "hash": "f68a05d2c78cc060b43c81b177a24f89c71e7e00dfa5af08ff4584bbe43b4155" 27 + }
+1 -1
docs/install-alpine.md
··· 137 137 138 138 ```sh 139 139 mkdir -p /opt && cd /opt 140 - git clone https://tangled.org/lewis.moe/bspds.git 140 + git clone https://tangled.org/lewis.moe/bspds-sandbox bspds 141 141 cd bspds 142 142 143 143 cd frontend
+2 -2
docs/install-containers.md
··· 92 92 93 93 ```bash 94 94 cd /opt 95 - git clone https://tangled.org/lewis.moe/bspds.git 95 + git clone https://tangled.org/lewis.moe/bspds-sandbox bspds 96 96 cd bspds 97 97 podman build -t bspds:latest . 98 98 ``` ··· 208 208 209 209 ```sh 210 210 cd /opt 211 - git clone https://tangled.org/lewis.moe/bspds.git 211 + git clone https://tangled.org/lewis.moe/bspds-sandbox bspds 212 212 cd bspds 213 213 podman build -t bspds:latest . 214 214 ```
+1 -1
docs/install-debian.md
··· 128 128 129 129 ```bash 130 130 cd /opt 131 - git clone https://tangled.org/lewis.moe/bspds.git 131 + git clone https://tangled.org/lewis.moe/bspds-sandbox bspds 132 132 cd bspds 133 133 134 134 cd frontend
+1 -1
docs/install-openbsd.md
··· 139 139 140 140 ```sh 141 141 mkdir -p /opt && cd /opt 142 - git clone https://tangled.org/lewis.moe/bspds.git 142 + git clone https://tangled.org/lewis.moe/bspds-sandbox bspds 143 143 cd bspds 144 144 145 145 cd frontend
+6
frontend/src/lib/api.ts
··· 51 51 handle: string 52 52 email?: string 53 53 emailConfirmed?: boolean 54 + preferredChannel?: string 55 + preferredChannelVerified?: boolean 54 56 accessJwt: string 55 57 refreshJwt: string 56 58 } ··· 95 97 refreshJwt: string 96 98 handle: string 97 99 did: string 100 + email?: string 101 + emailConfirmed?: boolean 102 + preferredChannel?: string 103 + preferredChannelVerified?: boolean 98 104 } 99 105 100 106 export const api = {
+4
frontend/src/lib/auth.svelte.ts
··· 108 108 handle: result.handle, 109 109 accessJwt: result.accessJwt, 110 110 refreshJwt: result.refreshJwt, 111 + email: result.email, 112 + emailConfirmed: result.emailConfirmed, 113 + preferredChannel: result.preferredChannel, 114 + preferredChannelVerified: result.preferredChannelVerified, 111 115 } 112 116 state.session = session 113 117 saveSession(session)
+21 -1
frontend/src/routes/Dashboard.svelte
··· 32 32 <dt>DID</dt> 33 33 <dd class="mono">{auth.session.did}</dd> 34 34 35 - {#if auth.session.email} 35 + {#if auth.session.preferredChannel} 36 + <dt>Primary Contact</dt> 37 + <dd> 38 + {#if auth.session.preferredChannel === 'email'} 39 + {auth.session.email || 'Email'} 40 + {:else if auth.session.preferredChannel === 'discord'} 41 + Discord 42 + {:else if auth.session.preferredChannel === 'telegram'} 43 + Telegram 44 + {:else if auth.session.preferredChannel === 'signal'} 45 + Signal 46 + {:else} 47 + {auth.session.preferredChannel} 48 + {/if} 49 + {#if auth.session.preferredChannelVerified} 50 + <span class="badge success">Verified</span> 51 + {:else} 52 + <span class="badge warning">Unverified</span> 53 + {/if} 54 + </dd> 55 + {:else if auth.session.email} 36 56 <dt>Email</dt> 37 57 <dd> 38 58 {auth.session.email}
+1 -1
frontend/src/routes/Register.svelte
··· 205 205 disabled={submitting} 206 206 required 207 207 maxlength="6" 208 - pattern="[0-9]{6}" 208 + inputmode="numeric" 209 209 autocomplete="one-time-code" 210 210 /> 211 211 </div>
+645
scripts/install-debian.sh
··· 1 + #!/bin/bash 2 + set -euo pipefail 3 + 4 + RED='\033[0;31m' 5 + GREEN='\033[0;32m' 6 + YELLOW='\033[1;33m' 7 + BLUE='\033[0;34m' 8 + CYAN='\033[0;36m' 9 + NC='\033[0m' 10 + 11 + log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } 12 + log_success() { echo -e "${GREEN}[OK]${NC} $1"; } 13 + log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 14 + log_error() { echo -e "${RED}[ERROR]${NC} $1"; } 15 + 16 + if [[ $EUID -ne 0 ]]; then 17 + log_error "This script must be run as root" 18 + exit 1 19 + fi 20 + 21 + if ! grep -qi "debian" /etc/os-release 2>/dev/null; then 22 + log_warn "This script is designed for Debian. Proceed with caution on other distros." 23 + fi 24 + 25 + echo -e "${CYAN}" 26 + echo "╔═══════════════════════════════════════════════════════════════════╗" 27 + echo "║ BSPDS Installation Script for Debian ║" 28 + echo "║ AT Protocol Personal Data Server in Rust ║" 29 + echo "╚═══════════════════════════════════════════════════════════════════╝" 30 + echo -e "${NC}" 31 + 32 + get_public_ips() { 33 + IPV4=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || curl -4 -s --max-time 5 icanhazip.com 2>/dev/null || echo "Could not detect") 34 + IPV6=$(curl -6 -s --max-time 5 ifconfig.me 2>/dev/null || curl -6 -s --max-time 5 icanhazip.com 2>/dev/null || echo "Not available") 35 + } 36 + 37 + log_info "Detecting public IP addresses..." 38 + get_public_ips 39 + 40 + echo "" 41 + echo -e "${CYAN}Your server's public IPs:${NC}" 42 + echo -e " IPv4: ${GREEN}${IPV4}${NC}" 43 + echo -e " IPv6: ${GREEN}${IPV6}${NC}" 44 + echo "" 45 + 46 + read -p "Enter your PDS domain (e.g., pds.example.com): " PDS_DOMAIN 47 + if [[ -z "$PDS_DOMAIN" ]]; then 48 + log_error "Domain cannot be empty" 49 + exit 1 50 + fi 51 + 52 + read -p "Enter your email for Let's Encrypt notifications: " CERTBOT_EMAIL 53 + if [[ -z "$CERTBOT_EMAIL" ]]; then 54 + log_error "Email cannot be empty" 55 + exit 1 56 + fi 57 + 58 + echo "" 59 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 60 + echo -e "${YELLOW}DNS RECORDS REQUIRED${NC}" 61 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 62 + echo "" 63 + echo "Before continuing, create these DNS records at your registrar:" 64 + echo "" 65 + echo -e "${GREEN}A Record:${NC}" 66 + echo " Name: ${PDS_DOMAIN}" 67 + echo " Type: A" 68 + echo " Value: ${IPV4}" 69 + echo "" 70 + if [[ "$IPV6" != "Not available" ]]; then 71 + echo -e "${GREEN}AAAA Record:${NC}" 72 + echo " Name: ${PDS_DOMAIN}" 73 + echo " Type: AAAA" 74 + echo " Value: ${IPV6}" 75 + echo "" 76 + fi 77 + echo -e "${GREEN}Wildcard A Record (for user handles):${NC}" 78 + echo " Name: *.${PDS_DOMAIN}" 79 + echo " Type: A" 80 + echo " Value: ${IPV4}" 81 + echo "" 82 + if [[ "$IPV6" != "Not available" ]]; then 83 + echo -e "${GREEN}Wildcard AAAA Record (for user handles):${NC}" 84 + echo " Name: *.${PDS_DOMAIN}" 85 + echo " Type: AAAA" 86 + echo " Value: ${IPV6}" 87 + echo "" 88 + fi 89 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 90 + echo "" 91 + read -p "Have you created these DNS records? (y/N): " DNS_CONFIRMED 92 + if [[ ! "$DNS_CONFIRMED" =~ ^[Yy]$ ]]; then 93 + log_warn "Please create the DNS records and run this script again." 94 + exit 0 95 + fi 96 + 97 + CREDENTIALS_FILE="/etc/bspds/.credentials" 98 + 99 + if [[ -f "$CREDENTIALS_FILE" ]]; then 100 + log_info "Loading existing credentials from previous installation..." 101 + source "$CREDENTIALS_FILE" 102 + log_success "Credentials loaded" 103 + else 104 + log_info "Generating secure secrets..." 105 + JWT_SECRET=$(openssl rand -base64 48) 106 + DPOP_SECRET=$(openssl rand -base64 48) 107 + MASTER_KEY=$(openssl rand -base64 48) 108 + DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) 109 + MINIO_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) 110 + 111 + mkdir -p /etc/bspds 112 + cat > "$CREDENTIALS_FILE" << EOF 113 + JWT_SECRET="$JWT_SECRET" 114 + DPOP_SECRET="$DPOP_SECRET" 115 + MASTER_KEY="$MASTER_KEY" 116 + DB_PASSWORD="$DB_PASSWORD" 117 + MINIO_PASSWORD="$MINIO_PASSWORD" 118 + EOF 119 + chmod 600 "$CREDENTIALS_FILE" 120 + log_success "Secrets generated and saved" 121 + fi 122 + 123 + log_info "Checking swap space..." 124 + TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') 125 + TOTAL_SWAP_KB=$(grep SwapTotal /proc/meminfo | awk '{print $2}') 126 + 127 + if [[ $TOTAL_SWAP_KB -lt 2000000 ]]; then 128 + log_info "Adding swap space (needed for compilation)..." 129 + if [[ ! -f /swapfile ]]; then 130 + SWAP_SIZE="4G" 131 + if [[ $TOTAL_MEM_KB -lt 2000000 ]]; then 132 + SWAP_SIZE="4G" 133 + elif [[ $TOTAL_MEM_KB -lt 4000000 ]]; then 134 + SWAP_SIZE="2G" 135 + fi 136 + fallocate -l $SWAP_SIZE /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=4096 137 + chmod 600 /swapfile 138 + mkswap /swapfile 139 + swapon /swapfile 140 + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab 141 + log_success "Swap space added ($SWAP_SIZE)" 142 + else 143 + swapon /swapfile 2>/dev/null || true 144 + log_success "Existing swap enabled" 145 + fi 146 + else 147 + log_success "Sufficient swap already configured" 148 + fi 149 + 150 + log_info "Updating system packages..." 151 + apt update && apt upgrade -y 152 + log_success "System updated" 153 + 154 + log_info "Installing build dependencies..." 155 + apt install -y curl git build-essential pkg-config libssl-dev ca-certificates gnupg lsb-release unzip xxd 156 + log_success "Build dependencies installed" 157 + 158 + log_info "Installing postgres..." 159 + apt install -y postgresql postgresql-contrib 160 + systemctl enable postgresql 161 + systemctl start postgresql 162 + 163 + sudo -u postgres psql -c "CREATE USER bspds WITH PASSWORD '${DB_PASSWORD}';" 2>/dev/null || \ 164 + sudo -u postgres psql -c "ALTER USER bspds WITH PASSWORD '${DB_PASSWORD}';" 165 + sudo -u postgres psql -c "CREATE DATABASE pds OWNER bspds;" 2>/dev/null || true 166 + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE pds TO bspds;" 167 + log_success "postgres installed and configured" 168 + 169 + log_info "Installing valkey..." 170 + apt install -y valkey || { 171 + log_warn "valkey not in repos, trying redis..." 172 + apt install -y redis-server 173 + systemctl enable redis-server 174 + systemctl start redis-server 175 + } 176 + systemctl enable valkey-server 2>/dev/null || true 177 + systemctl start valkey-server 2>/dev/null || true 178 + log_success "valkey/redis installed" 179 + 180 + log_info "Installing minio..." 181 + if [[ ! -f /usr/local/bin/minio ]]; then 182 + ARCH=$(dpkg --print-architecture) 183 + if [[ "$ARCH" == "amd64" ]]; then 184 + curl -fsSL -o /tmp/minio https://dl.min.io/server/minio/release/linux-amd64/minio 185 + elif [[ "$ARCH" == "arm64" ]]; then 186 + curl -fsSL -o /tmp/minio https://dl.min.io/server/minio/release/linux-arm64/minio 187 + else 188 + log_error "Unsupported architecture: $ARCH" 189 + exit 1 190 + fi 191 + chmod +x /tmp/minio 192 + mv /tmp/minio /usr/local/bin/ 193 + fi 194 + 195 + mkdir -p /var/lib/minio/data 196 + id -u minio-user &>/dev/null || useradd -r -s /sbin/nologin minio-user 197 + chown -R minio-user:minio-user /var/lib/minio 198 + 199 + cat > /etc/default/minio << EOF 200 + MINIO_ROOT_USER=minioadmin 201 + MINIO_ROOT_PASSWORD=${MINIO_PASSWORD} 202 + MINIO_VOLUMES="/var/lib/minio/data" 203 + MINIO_OPTS="--console-address :9001" 204 + EOF 205 + chmod 600 /etc/default/minio 206 + 207 + cat > /etc/systemd/system/minio.service << 'EOF' 208 + [Unit] 209 + Description=MinIO Object Storage 210 + After=network.target 211 + 212 + [Service] 213 + User=minio-user 214 + Group=minio-user 215 + EnvironmentFile=/etc/default/minio 216 + ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS 217 + Restart=always 218 + LimitNOFILE=65536 219 + 220 + [Install] 221 + WantedBy=multi-user.target 222 + EOF 223 + 224 + systemctl daemon-reload 225 + systemctl enable minio 226 + systemctl start minio 227 + log_success "minio installed" 228 + 229 + log_info "Waiting for minio to start..." 230 + sleep 5 231 + 232 + log_info "Installing minio client and creating bucket..." 233 + if [[ ! -f /usr/local/bin/mc ]]; then 234 + ARCH=$(dpkg --print-architecture) 235 + if [[ "$ARCH" == "amd64" ]]; then 236 + curl -fsSL -o /tmp/mc https://dl.min.io/client/mc/release/linux-amd64/mc 237 + elif [[ "$ARCH" == "arm64" ]]; then 238 + curl -fsSL -o /tmp/mc https://dl.min.io/client/mc/release/linux-arm64/mc 239 + fi 240 + chmod +x /tmp/mc 241 + mv /tmp/mc /usr/local/bin/ 242 + fi 243 + 244 + mc alias remove local 2>/dev/null || true 245 + mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 246 + mc mb local/pds-blobs --ignore-existing 247 + log_success "minio bucket created" 248 + 249 + log_info "Installing rust..." 250 + if [[ -f "$HOME/.cargo/env" ]]; then 251 + source "$HOME/.cargo/env" 252 + fi 253 + if ! command -v rustc &>/dev/null; then 254 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 255 + source "$HOME/.cargo/env" 256 + fi 257 + log_success "rust installed" 258 + 259 + log_info "Installing deno..." 260 + export PATH="$HOME/.deno/bin:$PATH" 261 + if ! command -v deno &>/dev/null && [[ ! -f "$HOME/.deno/bin/deno" ]]; then 262 + curl -fsSL https://deno.land/install.sh | sh 263 + grep -q 'deno/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc 264 + fi 265 + log_success "deno installed" 266 + 267 + log_info "Cloning BSPDS..." 268 + if [[ ! -d /opt/bspds ]]; then 269 + git clone https://tangled.org/lewis.moe/bspds-sandbox /opt/bspds 270 + else 271 + log_warn "/opt/bspds already exists, pulling latest..." 272 + cd /opt/bspds && git pull 273 + fi 274 + cd /opt/bspds 275 + log_success "BSPDS cloned" 276 + 277 + log_info "Building frontend..." 278 + cd /opt/bspds/frontend 279 + "$HOME/.deno/bin/deno" task build 280 + cd /opt/bspds 281 + log_success "Frontend built" 282 + 283 + log_info "Building BSPDS (this may take a while)..." 284 + source "$HOME/.cargo/env" 285 + NPROC=$(nproc) 286 + if [[ $TOTAL_MEM_KB -lt 4000000 ]]; then 287 + log_info "Low memory detected, limiting parallel jobs..." 288 + CARGO_BUILD_JOBS=1 cargo build --release 289 + else 290 + cargo build --release 291 + fi 292 + log_success "BSPDS built" 293 + 294 + log_info "Installing sqlx-cli and running migrations..." 295 + cargo install sqlx-cli --no-default-features --features postgres 296 + export DATABASE_URL="postgres://bspds:${DB_PASSWORD}@localhost:5432/pds" 297 + "$HOME/.cargo/bin/sqlx" migrate run 298 + log_success "Migrations complete" 299 + 300 + log_info "Setting up mail trap for testing..." 301 + mkdir -p /var/spool/bspds-mail 302 + chown root:root /var/spool/bspds-mail 303 + chmod 1777 /var/spool/bspds-mail 304 + 305 + cat > /usr/local/bin/bspds-sendmail << 'SENDMAIL_EOF' 306 + #!/bin/bash 307 + MAIL_DIR="/var/spool/bspds-mail" 308 + TIMESTAMP=$(date +%Y%m%d-%H%M%S) 309 + RANDOM_ID=$(head -c 4 /dev/urandom | xxd -p) 310 + MAIL_FILE="${MAIL_DIR}/${TIMESTAMP}-${RANDOM_ID}.eml" 311 + 312 + mkdir -p "$MAIL_DIR" 313 + 314 + { 315 + echo "X-BSPDS-Received: $(date -Iseconds)" 316 + echo "X-BSPDS-Args: $*" 317 + echo "" 318 + cat 319 + } > "$MAIL_FILE" 320 + 321 + chmod 644 "$MAIL_FILE" 322 + echo "Mail saved to: $MAIL_FILE" >&2 323 + exit 0 324 + SENDMAIL_EOF 325 + chmod +x /usr/local/bin/bspds-sendmail 326 + 327 + cat > /usr/local/bin/bspds-mailq << 'MAILQ_EOF' 328 + #!/bin/bash 329 + MAIL_DIR="/var/spool/bspds-mail" 330 + RED='\033[0;31m' 331 + GREEN='\033[0;32m' 332 + YELLOW='\033[1;33m' 333 + BLUE='\033[0;34m' 334 + CYAN='\033[0;36m' 335 + NC='\033[0m' 336 + 337 + show_help() { 338 + echo "bspds-mailq - View captured emails from BSPDS mail trap" 339 + echo "" 340 + echo "Usage:" 341 + echo " bspds-mailq List all captured emails" 342 + echo " bspds-mailq <number> View email by number (from list)" 343 + echo " bspds-mailq <filename> View email by filename" 344 + echo " bspds-mailq latest View the most recent email" 345 + echo " bspds-mailq clear Delete all captured emails" 346 + echo " bspds-mailq watch Watch for new emails (tail -f style)" 347 + echo " bspds-mailq count Show count of emails in queue" 348 + echo "" 349 + } 350 + 351 + list_emails() { 352 + if [[ ! -d "$MAIL_DIR" ]] || [[ -z "$(ls -A "$MAIL_DIR" 2>/dev/null)" ]]; then 353 + echo -e "${YELLOW}No emails in queue.${NC}" 354 + return 355 + fi 356 + 357 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 358 + echo -e "${GREEN} BSPDS Mail Queue${NC}" 359 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 360 + echo "" 361 + 362 + local i=1 363 + for f in $(ls -t "$MAIL_DIR"/*.eml 2>/dev/null); do 364 + local filename=$(basename "$f") 365 + local received=$(grep "^X-BSPDS-Received:" "$f" 2>/dev/null | cut -d' ' -f2-) 366 + local to=$(grep -i "^To:" "$f" 2>/dev/null | head -1 | cut -d' ' -f2-) 367 + local subject=$(grep -i "^Subject:" "$f" 2>/dev/null | head -1 | sed 's/^Subject: *//') 368 + 369 + echo -e "${BLUE}[$i]${NC} ${filename}" 370 + echo -e " To: ${GREEN}${to:-unknown}${NC}" 371 + echo -e " Subject: ${YELLOW}${subject:-<no subject>}${NC}" 372 + echo -e " Received: ${received:-unknown}" 373 + echo "" 374 + ((i++)) 375 + done 376 + 377 + echo -e "${CYAN}Total: $((i-1)) email(s)${NC}" 378 + } 379 + 380 + view_email() { 381 + local target="$1" 382 + local file="" 383 + 384 + if [[ "$target" == "latest" ]]; then 385 + file=$(ls -t "$MAIL_DIR"/*.eml 2>/dev/null | head -1) 386 + elif [[ "$target" =~ ^[0-9]+$ ]]; then 387 + file=$(ls -t "$MAIL_DIR"/*.eml 2>/dev/null | sed -n "${target}p") 388 + elif [[ -f "$MAIL_DIR/$target" ]]; then 389 + file="$MAIL_DIR/$target" 390 + elif [[ -f "$target" ]]; then 391 + file="$target" 392 + fi 393 + 394 + if [[ -z "$file" ]] || [[ ! -f "$file" ]]; then 395 + echo -e "${RED}Email not found: $target${NC}" 396 + return 1 397 + fi 398 + 399 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 400 + echo -e "${GREEN} $(basename "$file")${NC}" 401 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 402 + cat "$file" 403 + echo "" 404 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 405 + } 406 + 407 + clear_queue() { 408 + local count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l) 409 + if [[ "$count" -eq 0 ]]; then 410 + echo -e "${YELLOW}Queue is already empty.${NC}" 411 + return 412 + fi 413 + 414 + rm -f "$MAIL_DIR"/*.eml 415 + echo -e "${GREEN}Cleared $count email(s) from queue.${NC}" 416 + } 417 + 418 + watch_queue() { 419 + echo -e "${CYAN}Watching for new emails... (Ctrl+C to stop)${NC}" 420 + echo "" 421 + 422 + local last_count=0 423 + while true; do 424 + local current_count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l) 425 + if [[ "$current_count" -gt "$last_count" ]]; then 426 + echo -e "${GREEN}[$(date +%H:%M:%S)] New email received!${NC}" 427 + view_email latest 428 + last_count=$current_count 429 + fi 430 + sleep 1 431 + done 432 + } 433 + 434 + count_queue() { 435 + local count=$(ls -1 "$MAIL_DIR"/*.eml 2>/dev/null | wc -l) 436 + echo "$count" 437 + } 438 + 439 + case "${1:-}" in 440 + ""|list) 441 + list_emails 442 + ;; 443 + latest|[0-9]*) 444 + view_email "$1" 445 + ;; 446 + clear) 447 + clear_queue 448 + ;; 449 + watch) 450 + watch_queue 451 + ;; 452 + count) 453 + count_queue 454 + ;; 455 + help|--help|-h) 456 + show_help 457 + ;; 458 + *) 459 + if [[ -f "$MAIL_DIR/$1" ]] || [[ -f "$1" ]]; then 460 + view_email "$1" 461 + else 462 + echo -e "${RED}Unknown command: $1${NC}" 463 + show_help 464 + exit 1 465 + fi 466 + ;; 467 + esac 468 + MAILQ_EOF 469 + chmod +x /usr/local/bin/bspds-mailq 470 + log_success "Mail trap configured" 471 + 472 + log_info "Creating BSPDS configuration..." 473 + mkdir -p /etc/bspds 474 + 475 + cat > /etc/bspds/bspds.env << EOF 476 + SERVER_HOST=127.0.0.1 477 + SERVER_PORT=3000 478 + PDS_HOSTNAME=${PDS_DOMAIN} 479 + 480 + DATABASE_URL=postgres://bspds:${DB_PASSWORD}@localhost:5432/pds 481 + DATABASE_MAX_CONNECTIONS=100 482 + DATABASE_MIN_CONNECTIONS=10 483 + 484 + S3_ENDPOINT=http://localhost:9000 485 + AWS_REGION=us-east-1 486 + S3_BUCKET=pds-blobs 487 + AWS_ACCESS_KEY_ID=minioadmin 488 + AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 489 + 490 + VALKEY_URL=redis://localhost:6379 491 + 492 + JWT_SECRET=${JWT_SECRET} 493 + DPOP_SECRET=${DPOP_SECRET} 494 + MASTER_KEY=${MASTER_KEY} 495 + 496 + PLC_DIRECTORY_URL=https://plc.directory 497 + APPVIEW_URL=https://api.bsky.app 498 + CRAWLERS=https://bsky.network 499 + 500 + AVAILABLE_USER_DOMAINS=${PDS_DOMAIN} 501 + 502 + MAIL_FROM_ADDRESS=noreply@${PDS_DOMAIN} 503 + MAIL_FROM_NAME=BSPDS 504 + SENDMAIL_PATH=/usr/local/bin/bspds-sendmail 505 + EOF 506 + chmod 600 /etc/bspds/bspds.env 507 + log_success "Configuration created" 508 + 509 + log_info "Creating BSPDS service user..." 510 + id -u bspds &>/dev/null || useradd -r -s /sbin/nologin bspds 511 + 512 + cp /opt/bspds/target/release/bspds /usr/local/bin/ 513 + mkdir -p /var/lib/bspds 514 + cp -r /opt/bspds/frontend/dist /var/lib/bspds/frontend 515 + chown -R bspds:bspds /var/lib/bspds 516 + log_success "BSPDS binary installed" 517 + 518 + log_info "Creating systemd service..." 519 + cat > /etc/systemd/system/bspds.service << 'EOF' 520 + [Unit] 521 + Description=BSPDS - AT Protocol PDS 522 + After=network.target postgresql.service minio.service 523 + 524 + [Service] 525 + Type=simple 526 + User=bspds 527 + Group=bspds 528 + EnvironmentFile=/etc/bspds/bspds.env 529 + Environment=FRONTEND_DIR=/var/lib/bspds/frontend 530 + ExecStart=/usr/local/bin/bspds 531 + Restart=always 532 + RestartSec=5 533 + 534 + [Install] 535 + WantedBy=multi-user.target 536 + EOF 537 + 538 + systemctl daemon-reload 539 + systemctl enable bspds 540 + systemctl start bspds 541 + log_success "BSPDS service created and started" 542 + 543 + log_info "Installing nginx..." 544 + apt install -y nginx certbot python3-certbot-nginx 545 + log_success "nginx installed" 546 + 547 + log_info "Configuring nginx..." 548 + cat > /etc/nginx/sites-available/bspds << EOF 549 + server { 550 + listen 80; 551 + listen [::]:80; 552 + server_name ${PDS_DOMAIN} *.${PDS_DOMAIN}; 553 + 554 + location / { 555 + proxy_pass http://127.0.0.1:3000; 556 + proxy_http_version 1.1; 557 + proxy_set_header Upgrade \$http_upgrade; 558 + proxy_set_header Connection "upgrade"; 559 + proxy_set_header Host \$host; 560 + proxy_set_header X-Real-IP \$remote_addr; 561 + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; 562 + proxy_set_header X-Forwarded-Proto \$scheme; 563 + proxy_read_timeout 86400; 564 + proxy_send_timeout 86400; 565 + client_max_body_size 100M; 566 + } 567 + } 568 + EOF 569 + 570 + ln -sf /etc/nginx/sites-available/bspds /etc/nginx/sites-enabled/ 571 + rm -f /etc/nginx/sites-enabled/default 572 + nginx -t 573 + systemctl reload nginx 574 + log_success "nginx configured" 575 + 576 + log_info "Configuring firewall (ufw)..." 577 + apt install -y ufw 578 + ufw --force reset 579 + 580 + ufw default deny incoming 581 + ufw default allow outgoing 582 + 583 + ufw allow ssh comment 'SSH' 584 + ufw allow 80/tcp comment 'HTTP' 585 + ufw allow 443/tcp comment 'HTTPS' 586 + 587 + ufw --force enable 588 + log_success "Firewall configured" 589 + 590 + log_info "Obtaining SSL certificate..." 591 + certbot --nginx -d "${PDS_DOMAIN}" -d "*.${PDS_DOMAIN}" --email "${CERTBOT_EMAIL}" --agree-tos --non-interactive || { 592 + log_warn "Wildcard cert failed (requires DNS challenge). Trying single domain..." 593 + certbot --nginx -d "${PDS_DOMAIN}" --email "${CERTBOT_EMAIL}" --agree-tos --non-interactive 594 + } 595 + log_success "SSL certificate obtained" 596 + 597 + log_info "Verifying installation..." 598 + sleep 3 599 + if curl -s "http://localhost:3000/xrpc/_health" | grep -q "version"; then 600 + log_success "BSPDS is responding!" 601 + else 602 + log_warn "BSPDS may still be starting up. Check: journalctl -u bspds -f" 603 + fi 604 + 605 + echo "" 606 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 607 + echo -e "${GREEN} INSTALLATION COMPLETE!${NC}" 608 + echo -e "${CYAN}═══════════════════════════════════════════════════════════════════${NC}" 609 + echo "" 610 + echo -e "Your PDS is now running at: ${GREEN}https://${PDS_DOMAIN}${NC}" 611 + echo "" 612 + echo -e "${YELLOW}IMPORTANT: Save these credentials securely!${NC}" 613 + echo "" 614 + echo "Database password: ${DB_PASSWORD}" 615 + echo "MinIO password: ${MINIO_PASSWORD}" 616 + echo "" 617 + echo "Configuration file: /etc/bspds/bspds.env" 618 + echo "" 619 + echo -e "${CYAN}Useful commands:${NC}" 620 + echo " journalctl -u bspds -f # View BSPDS logs" 621 + echo " systemctl status bspds # Check BSPDS status" 622 + echo " systemctl restart bspds # Restart BSPDS" 623 + echo " curl https://${PDS_DOMAIN}/xrpc/_health # Health check" 624 + echo "" 625 + echo -e "${CYAN}Mail queue (for testing):${NC}" 626 + echo " bspds-mailq # List all captured emails" 627 + echo " bspds-mailq latest # View most recent email" 628 + echo " bspds-mailq 1 # View email #1 from list" 629 + echo " bspds-mailq watch # Watch for new emails live" 630 + echo " bspds-mailq clear # Clear all captured emails" 631 + echo "" 632 + echo " Emails are saved to: /var/spool/bspds-mail/" 633 + echo "" 634 + echo -e "${CYAN}DNS Records Summary:${NC}" 635 + echo "" 636 + echo " ${PDS_DOMAIN} A ${IPV4}" 637 + if [[ "$IPV6" != "Not available" ]]; then 638 + echo " ${PDS_DOMAIN} AAAA ${IPV6}" 639 + fi 640 + echo " *.${PDS_DOMAIN} A ${IPV4}" 641 + if [[ "$IPV6" != "Not available" ]]; then 642 + echo " *.${PDS_DOMAIN} AAAA ${IPV6}" 643 + fi 644 + echo "" 645 + echo -e "${GREEN}Enjoy your new AT Protocol PDS!${NC}"
+14 -4
src/api/identity/plc/submit.rs
··· 218 218 } 219 219 } 220 220 221 - if let Err(e) = sqlx::query!( 222 - "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity')", 221 + match sqlx::query!( 222 + "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity') RETURNING seq", 223 223 did 224 224 ) 225 - .execute(&state.db) 225 + .fetch_one(&state.db) 226 226 .await 227 227 { 228 - warn!("Failed to sequence identity event: {:?}", e); 228 + Ok(row) => { 229 + if let Err(e) = sqlx::query(&format!("NOTIFY repo_updates, '{}'", row.seq)) 230 + .execute(&state.db) 231 + .await 232 + { 233 + warn!("Failed to notify identity event: {:?}", e); 234 + } 235 + } 236 + Err(e) => { 237 + warn!("Failed to sequence identity event: {:?}", e); 238 + } 229 239 } 230 240 231 241 info!("Submitted PLC operation for user {}", did);
+7 -2
src/api/repo/import.rs
··· 390 390 let blobs: Vec<String> = vec![]; 391 391 let blocks_cids: Vec<String> = vec![]; 392 392 393 - sqlx::query!( 393 + let seq_row = sqlx::query!( 394 394 r#" 395 395 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids) 396 396 VALUES ($1, 'commit', $2, $3, $4, $5, $6) 397 + RETURNING seq 397 398 "#, 398 399 did, 399 400 commit_cid, ··· 402 403 &blobs, 403 404 &blocks_cids 404 405 ) 405 - .execute(&state.db) 406 + .fetch_one(&state.db) 406 407 .await?; 408 + 409 + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) 410 + .execute(&state.db) 411 + .await?; 407 412 408 413 Ok(()) 409 414 }
+72 -19
src/api/server/session.rs
··· 176 176 State(state): State<AppState>, 177 177 BearerAuth(auth_user): BearerAuth, 178 178 ) -> Response { 179 - match sqlx::query!("SELECT handle, email FROM users WHERE did = $1", auth_user.did) 180 - .fetch_optional(&state.db) 181 - .await 179 + match sqlx::query!( 180 + r#"SELECT 181 + handle, email, email_confirmed, 182 + preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 183 + discord_verified, telegram_verified, signal_verified 184 + FROM users WHERE did = $1"#, 185 + auth_user.did 186 + ) 187 + .fetch_optional(&state.db) 188 + .await 182 189 { 183 - Ok(Some(row)) => Json(json!({ 184 - "handle": row.handle, 185 - "did": auth_user.did, 186 - "email": row.email, 187 - "didDoc": {} 188 - })).into_response(), 190 + Ok(Some(row)) => { 191 + let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 192 + crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed), 193 + crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified), 194 + crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified), 195 + crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified), 196 + }; 197 + Json(json!({ 198 + "handle": row.handle, 199 + "did": auth_user.did, 200 + "email": row.email, 201 + "emailConfirmed": row.email_confirmed, 202 + "preferredChannel": preferred_channel, 203 + "preferredChannelVerified": preferred_channel_verified, 204 + "didDoc": {} 205 + })).into_response() 206 + } 189 207 Ok(None) => ApiError::AuthenticationFailed.into_response(), 190 208 Err(e) => { 191 209 error!("Database error in get_session: {:?}", e); ··· 373 391 return ApiError::InternalError.into_response(); 374 392 } 375 393 376 - match sqlx::query!("SELECT handle FROM users WHERE did = $1", session_row.did) 377 - .fetch_optional(&state.db) 378 - .await 394 + match sqlx::query!( 395 + r#"SELECT 396 + handle, email, email_confirmed, 397 + preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 398 + discord_verified, telegram_verified, signal_verified 399 + FROM users WHERE did = $1"#, 400 + session_row.did 401 + ) 402 + .fetch_optional(&state.db) 403 + .await 379 404 { 380 - Ok(Some(u)) => Json(json!({ 381 - "accessJwt": new_access_meta.token, 382 - "refreshJwt": new_refresh_meta.token, 383 - "handle": u.handle, 384 - "did": session_row.did 385 - })).into_response(), 405 + Ok(Some(u)) => { 406 + let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 407 + crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed), 408 + crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified), 409 + crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified), 410 + crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified), 411 + }; 412 + Json(json!({ 413 + "accessJwt": new_access_meta.token, 414 + "refreshJwt": new_refresh_meta.token, 415 + "handle": u.handle, 416 + "did": session_row.did, 417 + "email": u.email, 418 + "emailConfirmed": u.email_confirmed, 419 + "preferredChannel": preferred_channel, 420 + "preferredChannelVerified": preferred_channel_verified 421 + })).into_response() 422 + } 386 423 Ok(None) => { 387 424 error!("User not found for existing session: {}", session_row.did); 388 425 ApiError::InternalError.into_response() ··· 408 445 pub refresh_jwt: String, 409 446 pub handle: String, 410 447 pub did: String, 448 + pub email: Option<String>, 449 + pub email_confirmed: bool, 450 + pub preferred_channel: String, 451 + pub preferred_channel_verified: bool, 411 452 } 412 453 413 454 pub async fn confirm_signup( ··· 418 459 419 460 let row = match sqlx::query!( 420 461 r#"SELECT 421 - u.id, u.did, u.handle, 462 + u.id, u.did, u.handle, u.email, 422 463 u.email_confirmation_code, 423 464 u.email_confirmation_code_expires_at, 424 465 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", ··· 527 568 warn!("Failed to enqueue welcome notification: {:?}", e); 528 569 } 529 570 571 + let email_confirmed = matches!(row.channel, crate::notifications::NotificationChannel::Email); 572 + let preferred_channel = match row.channel { 573 + crate::notifications::NotificationChannel::Email => "email", 574 + crate::notifications::NotificationChannel::Discord => "discord", 575 + crate::notifications::NotificationChannel::Telegram => "telegram", 576 + crate::notifications::NotificationChannel::Signal => "signal", 577 + }; 578 + 530 579 Json(ConfirmSignupOutput { 531 580 access_jwt: access_meta.token, 532 581 refresh_jwt: refresh_meta.token, 533 582 handle: row.handle, 534 583 did: row.did, 584 + email: row.email, 585 + email_confirmed, 586 + preferred_channel: preferred_channel.to_string(), 587 + preferred_channel_verified: true, 535 588 }).into_response() 536 589 } 537 590
+9 -1
src/sync/listener.rs
··· 57 57 loop { 58 58 let notification = listener.recv().await?; 59 59 let payload = notification.payload(); 60 + debug!(payload = %payload, "Received postgres notification"); 60 61 61 62 let seq_id: i64 = match payload.parse() { 62 63 Ok(id) => id, ··· 110 111 .await?; 111 112 112 113 if let Some(event) = event { 113 - let _ = state.firehose_tx.send(event); 114 + match state.firehose_tx.send(event) { 115 + Ok(receiver_count) => { 116 + debug!(seq = seq_id, receivers = receiver_count, "Broadcast event to firehose"); 117 + } 118 + Err(e) => { 119 + warn!(seq = seq_id, error = %e, "Failed to broadcast event (no receivers?)"); 120 + } 121 + } 114 122 LAST_BROADCAST_SEQ.store(seq_id, Ordering::SeqCst); 115 123 } else { 116 124 warn!(seq = seq_id, "Received notification but could not find row in repo_seq");