ryan.doesthings.online

Bluesky PDS Change

In case you also put your PDS on your base domain and greatly regretted it >.>

update_pds.sh

#!/usr/bin/env bash
set -euo pipefail

# =============================================================================
# Bluesky PDS DID Endpoint Update Script
# Updates the atproto_pds endpoint in your DID document via PLC directory
# =============================================================================

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Error codes
readonly ERR_GENERAL=1
readonly ERR_MISSING_DEPS=2
readonly ERR_INVALID_ARGS=3
readonly ERR_AUTH_FAILED=4
readonly ERR_OPERATION_FAILED=5
readonly ERR_USER_CANCELLED=6
readonly ERR_NETWORK_ERROR=7

# Centralized error reporting function
report_error() {
    local code="$1"
    local message="$2"
    local suggestion="${3:-}"
    
    echo -e "${RED}Error: $message${NC}" >&2
    [[ -n "$suggestion" ]] && echo -e "${YELLOW}Suggestion: $suggestion${NC}" >&2
    return "$code"
}

# Centralized success reporting function
report_success() {
    local message="$1"
    echo -e "${GREEN}$message${NC}"
}

# Centralized warning reporting function
report_warning() {
    local message="$1"
    echo -e "${YELLOW}$message${NC}"
}

# Centralized info reporting function
report_info() {
    local message="$1"
    echo -e "${BLUE}$message${NC}"
}

# Check required dependencies
check_prerequisites() {
    local missing=()
    
    command -v curl >/dev/null 2>&1 || missing+=("curl")
    command -v jq >/dev/null 2>&1 || missing+=("jq")
    
    if [[ ${#missing[@]} -gt 0 ]]; then
        report_error $ERR_MISSING_DEPS "Missing required tools: ${missing[*]}" \
            "Install missing dependencies before running this script"
        return $ERR_MISSING_DEPS
    fi
    
    return 0
}

# Normalize URL by removing trailing slashes
normalize_base() {
    local u="${1:-}"
    while [[ "$u" == */ ]]; do u="${u%/}"; done
    echo "$u"
}

# Validate URL format
validate_url() {
    local url="$1"
    local name="$2"
    
    if [[ -z "$url" ]]; then
        report_error $ERR_INVALID_ARGS "$name cannot be empty"
        return $ERR_INVALID_ARGS
    fi
    
    if [[ ! "$url" =~ ^https?:// ]]; then
        report_error $ERR_INVALID_ARGS "$name must start with http:// or https://" \
            "Example: https://pds.example.com"
        return $ERR_INVALID_ARGS
    fi
    
    return 0
}

# Prompt for user input
prompt() {
    local var="$1" msg="$2" secret="${3:-false}" val
    if [[ "$secret" == "true" ]]; then
        read -r -s -p "$msg" val </dev/tty >&2; echo >&2
    else
        read -r -p "$msg" val </dev/tty >&2
    fi
    printf -v "$var" '%s' "$val"
}

# Make HTTP request with error handling
http_request() {
    local method="$1"
    local url="$2"
    local auth_header="${3:-}"
    local content_type="${4:-}"
    local data="${5:-}"
    local body
    local http_code

    local curl_args=(-sS -X "$method")

    [[ -n "$auth_header" ]] && curl_args+=(-H "Authorization: $auth_header")
    [[ -n "$content_type" ]] && curl_args+=(-H "Content-Type: $content_type")
    [[ -n "$data" ]] && curl_args+=(-d "$data")

    # Write HTTP status code to fd 3 via a temp file descriptor approach
    local tmpfile
    tmpfile=$(mktemp)
    body=$(curl "${curl_args[@]}" -o - -w "%{http_code}" -- "$url" 2>"$tmpfile") || {
        local curl_err
        curl_err=$(<"$tmpfile")
        rm -f "$tmpfile"
        report_error $ERR_NETWORK_ERROR "Network request failed: $curl_err" \
            "Check your internet connection and that the PDS URL is correct"
        return $ERR_NETWORK_ERROR
    }
    rm -f "$tmpfile"

    # The status code is appended to the body by -w; extract it
    http_code="${body: -3}"
    body="${body:0:${#body}-3}"

    # Guard against non-numeric status codes
    if ! [[ "$http_code" =~ ^[0-9]{3}$ ]]; then
        report_error $ERR_NETWORK_ERROR "Unexpected response (no valid HTTP status code)"
        return $ERR_NETWORK_ERROR
    fi

    # Check for HTTP errors
    if [[ "$http_code" -ge 400 ]]; then
        local error_msg
        error_msg=$(jq -r '.message // .error // "Unknown error"' 2>/dev/null <<< "$body" || echo "$body")
        report_error $ERR_OPERATION_FAILED "HTTP $http_code: $error_msg"
        return $ERR_OPERATION_FAILED
    fi

    echo "$body"
    return 0
}

# Cleanup function for sensitive data
cleanup() {
    unset PASSWORD JWT TOKEN 2>/dev/null || true
}
trap cleanup EXIT INT TERM

# =============================================================================
# Main Script
# =============================================================================

main() {
    report_info "=== Bluesky PDS DID Endpoint Update ==="
    report_info "Updates the atproto_pds endpoint in your DID document"
    echo
    
    # Check prerequisites
    if ! check_prerequisites; then
        return $?
    fi
    
    # Gather input
    prompt PDS "Current PDS base URL (e.g. https://example.com): "
    PDS="$(normalize_base "$PDS")"
    if ! validate_url "$PDS" "Current PDS URL"; then
        return $?
    fi
    
    prompt NEW_PDS "New PDS base URL to write into DID doc (e.g. https://pds.example.com): "
    NEW_PDS="$(normalize_base "$NEW_PDS")"
    if ! validate_url "$NEW_PDS" "New PDS URL"; then
        return $?
    fi
    
    prompt IDENTIFIER "Account identifier (handle or email): "
    if [[ -z "$IDENTIFIER" ]]; then
        report_error $ERR_INVALID_ARGS "Account identifier cannot be empty"
        return $ERR_INVALID_ARGS
    fi
    
    prompt PASSWORD "Password (or app password): " true
    if [[ -z "$PASSWORD" ]]; then
        report_error $ERR_INVALID_ARGS "Password cannot be empty"
        return $ERR_INVALID_ARGS
    fi
    
    # Step 1: Create session
    echo
    report_info "Creating session on: $PDS"
    local session_payload
    session_payload=$(jq -n --arg id "$IDENTIFIER" --arg pw "$PASSWORD" '{identifier:$id,password:$pw}')

    # Password no longer needed
    unset PASSWORD

    local SESSION_JSON
    SESSION_JSON=$(http_request "POST" "$PDS/xrpc/com.atproto.server.createSession" "" "application/json" "$session_payload") || {
        report_error $ERR_AUTH_FAILED "Failed to create session" \
            "Check your credentials and PDS URL"
        return $ERR_AUTH_FAILED
    }

    local JWT DID HANDLE
    JWT="$(jq -r '.accessJwt // empty' <<< "$SESSION_JSON")"
    DID="$(jq -r '.did // empty' <<< "$SESSION_JSON")"
    HANDLE="$(jq -r '.handle // empty' <<< "$SESSION_JSON")"

    if [[ -z "$JWT" ]]; then
        report_error $ERR_AUTH_FAILED "Failed to create session - no access token received" \
            "Check your credentials"
        jq . >&2 <<< "$SESSION_JSON"
        return $ERR_AUTH_FAILED
    fi

    report_success "Session created for: $HANDLE ($DID)"
    
    # Step 2: Request PLC operation signature
    echo
    report_info "Requesting PLC operation signature email..."
    if ! http_request "POST" "$PDS/xrpc/com.atproto.identity.requestPlcOperationSignature" "Bearer $JWT" >/dev/null; then
        report_error $ERR_OPERATION_FAILED "Failed to request PLC operation signature"
        return $ERR_OPERATION_FAILED
    fi
    
    report_success "Verification email sent"
    report_warning "Check your email for the verification code"
    
    prompt TOKEN "Enter the emailed code (token): "
    if [[ -z "$TOKEN" ]]; then
        report_error $ERR_INVALID_ARGS "Token cannot be empty"
        return $ERR_INVALID_ARGS
    fi
    
    # Step 3: Sign PLC operation
    echo
    report_info "Signing PLC operation (service endpoint -> $NEW_PDS)..."
    local sign_payload
    sign_payload=$(jq -n --arg token "$TOKEN" --arg endpoint "$NEW_PDS" '{
        token: $token,
        services: {
            atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: $endpoint }
        }
    }')

    # Token no longer needed
    unset TOKEN

    local SIGNED_JSON
    SIGNED_JSON=$(http_request "POST" "$PDS/xrpc/com.atproto.identity.signPlcOperation" "Bearer $JWT" "application/json" "$sign_payload") || {
        report_error $ERR_OPERATION_FAILED "Failed to sign PLC operation" \
            "The verification code may be invalid or expired"
        return $ERR_OPERATION_FAILED
    }

    local OP
    OP="$(jq -c '.operation // empty' <<< "$SIGNED_JSON")"
    if [[ -z "$OP" ]]; then
        report_error $ERR_OPERATION_FAILED "Failed to sign PLC operation - no operation returned"
        jq . >&2 <<< "$SIGNED_JSON"
        return $ERR_OPERATION_FAILED
    fi

    report_success "PLC operation signed"
    
    # Step 4: Submit PLC operation
    echo
    report_info "Submitting PLC operation to PLC directory..."
    local submit_payload
    submit_payload=$(jq -n --argjson op "$OP" '{operation:$op}')
    
    local SUBMIT_JSON
    SUBMIT_JSON=$(http_request "POST" "$PDS/xrpc/com.atproto.identity.submitPlcOperation" "Bearer $JWT" "application/json" "$submit_payload") || {
        report_error $ERR_OPERATION_FAILED "Failed to submit PLC operation"
        return $ERR_OPERATION_FAILED
    }
    
    report_success "PLC operation submitted"
    
    # Step 5: Verify the update
    echo
    report_info "Verifying DID document via plc.directory..."
    sleep 2  # Brief delay for propagation

    # JWT no longer needed
    unset JWT

    local DID_DOC
    DID_DOC=$(http_request "GET" "https://plc.directory/$DID") || {
        report_warning "Could not verify DID document - check manually at: https://plc.directory/$DID"
        return 0
    }

    # Extract and display service information
    local SERVICE_INFO
    SERVICE_INFO=$(jq '.service' <<< "$DID_DOC")

    if [[ "$SERVICE_INFO" != "null" && -n "$SERVICE_INFO" ]]; then
        report_success "DID document updated successfully"
        echo
        report_info "Current service configuration:"
        jq . <<< "$SERVICE_INFO"

        # Verify the endpoint was updated correctly
        local CURRENT_ENDPOINT
        CURRENT_ENDPOINT=$(jq -r '.service[]? | select(.id == "#atproto_pds") | .serviceEndpoint // empty' <<< "$DID_DOC")

        if [[ "$CURRENT_ENDPOINT" == "$NEW_PDS" ]]; then
            report_success "Endpoint successfully updated to: $NEW_PDS"
        else
            report_warning "Endpoint may not have updated yet. Current value: $CURRENT_ENDPOINT"
            report_info "It may take a few moments for changes to propagate"
        fi
    else
        report_warning "Could not parse service information from DID document"
        jq . 2>/dev/null <<< "$DID_DOC" || echo "$DID_DOC"
    fi

    echo
    report_success "Done!"
    report_info "If you have multiple accounts on this PDS, run this script once per account."
}

# Run main function
main "$@"