A library for handling DID identifiers used in Bluesky AT Protocol

added YARD documentation for most classes

+320 -7
+5
lib/didkit/at_handles.rb
··· 1 1 require_relative 'errors' 2 2 3 3 module DIDKit 4 + 5 + # 6 + # @private 7 + # 8 + 4 9 module AtHandles 5 10 6 11 private
+87 -2
lib/didkit/did.rb
··· 6 6 require_relative 'resolver' 7 7 8 8 module DIDKit 9 + 10 + # 11 + # Represents a DID identifier (account on the ATProto network). This class serves as an entry 12 + # point to various lookup helpers. For convenience it can also be accessed as just `DID` without 13 + # the `DIDKit::` prefix. 14 + # 15 + # @example Resolving a handle 16 + # did = DID.resolve_handle('bsky.app') 17 + # 18 + 9 19 class DID 10 20 GENERIC_REGEXP = /\Adid\:\w+\:.+\z/ 11 21 12 22 include Requests 13 23 24 + # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method 25 + # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods. 26 + # 27 + # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this 28 + # method to pass it an input string from the user which can be a DID or handle, without having to 29 + # check which one it is. 30 + # 31 + # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string 32 + # @return [DID, nil] resolved DID if found, nil otherwise 33 + 14 34 def self.resolve_handle(handle) 15 35 Resolver.new.resolve_handle(handle) 16 36 end 17 37 18 - attr_reader :type, :did, :resolved_by 38 + # @return [Symbol] DID type (`:plc` or `:web`) 39 + attr_reader :type 40 + 41 + # @return [String] DID identifier string 42 + attr_reader :did 43 + 44 + # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods 45 + attr_reader :resolved_by 46 + 47 + alias to_s did 48 + 49 + 50 + # Create a DID object from a DID string. 51 + # 52 + # @param did [String, DID] DID string or another DID object 53 + # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`) 54 + # @raise [DIDError] when the DID format or type is invalid 19 55 20 56 def initialize(did, resolved_by = nil) 21 57 if did.is_a?(DID) ··· 36 72 @resolved_by = resolved_by 37 73 end 38 74 39 - alias to_s did 75 + # Returns or looks up the DID document with the DID's identity details from an appropriate source. 76 + # This method caches the document in a local variable if it's called again. 77 + # 78 + # @return [Document] resolved DID document 40 79 41 80 def document 42 81 @document ||= get_document 43 82 end 44 83 84 + # Looks up the DID document with the DID's identity details from an appropriate source. 85 + # @return [Document] resolved DID document 86 + 45 87 def get_document 46 88 Resolver.new.resolve_did(self) 47 89 end 48 90 91 + # Returns the first verified handle assigned to this DID. 92 + # 93 + # Looks up the domain handles assigned to this DID in its DID document, checks if they are 94 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 95 + # the first handle that validates correctly, or nil if none matches. 96 + # 97 + # @return [String, nil] verified handle domain, if found 98 + 49 99 def get_verified_handle 50 100 Resolver.new.get_verified_handle(document) 51 101 end 52 102 103 + # Fetches the PLC audit log (list of all previous operations) for a did:plc DID. 104 + # 105 + # @return [Array<PLCOperation>] list of PLC operations in the audit log 106 + # @raise [DIDError] when the DID is not a did:plc 107 + 53 108 def get_audit_log 54 109 if @type == :plc 55 110 PLCImporter.new.fetch_audit_log(self) ··· 58 113 end 59 114 end 60 115 116 + # Returns the domain portion of a did:web identifier. 117 + # 118 + # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc 119 + 61 120 def web_domain 62 121 did.gsub(/^did\:web\:/, '') if type == :web 63 122 end 64 123 124 + # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint. 125 + # 126 + # @param request_options [Hash] request options to override 127 + # @option request_options [Integer] :timeout request timeout (default: 15) 128 + # @option request_options [Integer] :max_redirects maximum number of redirects to follow (default: 5) 129 + # 130 + # @return [Symbol, nil] `:active`, or returned inactive status, or `nil` if account is not found 131 + # @raise [APIError] when the response is invalid 132 + 65 133 def account_status(request_options = {}) 66 134 doc = self.document 67 135 return nil if doc.pds_endpoint.nil? ··· 91 159 end 92 160 end 93 161 162 + # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint. 163 + # This is a helper which calls the {#account_status} method and checks if the status is `:active`. 164 + # 165 + # @return [Boolean] true if the returned status is active 166 + # @raise [APIError] when the response is invalid 167 + 94 168 def account_active? 95 169 account_status == :active 96 170 end 97 171 172 + # Checks if the account exists its own PDS, using the `getRepoStatus` endpoint. 173 + # This is a helper which calls the {#account_status} method and checks if the repo is found at all. 174 + # 175 + # @return [Boolean] true if the returned status is valid, false if repo is not found 176 + # @raise [APIError] when the response is invalid 177 + 98 178 def account_exists? 99 179 account_status != nil 100 180 end 181 + 182 + # Compares the DID to another DID object or string. 183 + # 184 + # @param other [DID, String] other DID to compare with 185 + # @return [Boolean] true if it's the same DID 101 186 102 187 def ==(other) 103 188 if other.is_a?(String)
+38 -1
lib/didkit/document.rb
··· 5 5 require_relative 'services' 6 6 7 7 module DIDKit 8 + 9 + # 10 + # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain. 11 + # 12 + # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object. 13 + # 14 + 8 15 class Document 9 16 include AtHandles 10 17 include Services 11 18 12 - attr_reader :json, :did, :handles, :services 19 + # @return [Hash] the complete JSON data of the DID document 20 + attr_reader :json 21 + 22 + # @return [DID] the DID that this document describes 23 + attr_reader :did 24 + 25 + # Returns a list of handles assigned to this DID in its DID document. 26 + # 27 + # Note: the handles aren't guaranteed to be verified (validated in the other direction). 28 + # Use {#get_verified_handle} to find a handle that is correctly verified. 29 + # 30 + # @return [Array<String>] 31 + attr_reader :handles 32 + 33 + # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID 34 + attr_reader :services 35 + 36 + # Creates a DID document object. 37 + # 38 + # @param did [DID] DID object 39 + # @param json [Hash] DID document JSON 40 + # @raise [FormatError] when required fields are missing or invalid. 13 41 14 42 def initialize(did, json) 15 43 raise FormatError, "Missing id field" if json['id'].nil? ··· 23 51 @handles = parse_also_known_as(json['alsoKnownAs'] || []) 24 52 end 25 53 54 + # Returns the first verified handle assigned to the DID. 55 + # 56 + # Looks up the domain handles assigned to this DID in the DID document, checks if they are 57 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 58 + # the first handle that validates correctly, or nil if none matches. 59 + # 60 + # @return [String, nil] verified handle domain, if found 61 + 26 62 def get_verified_handle 27 63 Resolver.new.get_verified_handle(self) 28 64 end 65 + 29 66 30 67 private 31 68
+17 -2
lib/didkit/errors.rb
··· 1 1 module DIDKit 2 - class DIDError < StandardError 3 - end 4 2 3 + # 4 + # Raised when an HTTP request returns a response with an error status. 5 + # 5 6 class APIError < StandardError 7 + 8 + # @return [Net::HTTPResponse] the returned HTTP response 6 9 attr_reader :response 7 10 11 + # @param response [Net::HTTPResponse] the returned HTTP response 8 12 def initialize(response) 9 13 @response = response 10 14 super("APIError: #{response}") 11 15 end 12 16 17 + # @return [Integer] HTTP status code 13 18 def status 14 19 response.code.to_i 15 20 end 16 21 22 + # @return [String] HTTP response body 17 23 def body 18 24 response.body 19 25 end 20 26 end 21 27 28 + # 29 + # Raised when a string is not a valid DID or not of the right type. 30 + # 31 + class DIDError < StandardError 32 + end 33 + 34 + # 35 + # Raised when the loaded data has some missing or invalid fields. 36 + # 22 37 class FormatError < StandardError 23 38 end 24 39 end
+43 -1
lib/didkit/plc_operation.rb
··· 6 6 require_relative 'services' 7 7 8 8 module DIDKit 9 + 10 + # 11 + # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory) 12 + # (e.g. changing assigned handles or migrating to a different PDS). 13 + # 14 + 9 15 class PLCOperation 10 16 include AtHandles 11 17 include Services 12 18 13 - attr_reader :json, :did, :cid, :seq, :created_at, :type, :handles, :services 19 + # @return [Hash] the JSON from which the operation is parsed 20 + attr_reader :json 21 + 22 + # @return [String] the DID which the operation concerns 23 + attr_reader :did 24 + 25 + # @return [String] CID (Content Identifier) of the operation 26 + attr_reader :cid 27 + 28 + # Returns a sequential number of the operation (only used in the new export API). 29 + # @return [Integer, nil] sequential number of the operation 30 + attr_reader :seq 31 + 32 + # @return [Time] time when the operation was created 33 + attr_reader :created_at 34 + 35 + # Returns the `type` field of the operation (usually `"plc_operation"`). 36 + # @return [String] the operation type 37 + attr_reader :type 38 + 39 + # Returns a list of handles assigned to the DID in this operation. 40 + # 41 + # Note: the handles aren't guaranteed to be verified (validated in the other direction). 42 + # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is 43 + # correctly verified. 44 + # 45 + # @return [Array<String>] 46 + attr_reader :handles 47 + 48 + # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID 49 + attr_reader :services 50 + 51 + 52 + # Creates a PLCOperation object. 53 + # 54 + # @param json [Hash] operation JSON 55 + # @raise [FormatError] when required fields are missing or invalid 14 56 15 57 def initialize(json) 16 58 @json = json
+5
lib/didkit/requests.rb
··· 5 5 require_relative 'errors' 6 6 7 7 module DIDKit 8 + 9 + # 10 + # @private 11 + # 12 + 8 13 module Requests 9 14 10 15 private
+63
lib/didkit/resolver.rb
··· 6 6 require_relative 'requests' 7 7 8 8 module DIDKit 9 + 10 + # 11 + # A class which manages resolving of handles to DIDs and DIDs to DID documents. 12 + # 13 + 9 14 class Resolver 15 + # These TLDs are not allowed in ATProto handles, so the resolver returns nil for them 16 + # without trying to look them up. 10 17 RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test) 11 18 12 19 include Requests 13 20 21 + # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups 14 22 attr_accessor :nameserver 15 23 24 + # @param options [Hash] resolver options 25 + # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs) 26 + # @option options [Integer] :timeout request timeout in seconds (default: 15) 27 + # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5) 28 + 16 29 def initialize(options = {}) 17 30 @nameserver = options[:nameserver] 18 31 @request_options = options.slice(:timeout, :max_redirects) 19 32 end 20 33 34 + # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method 35 + # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods. 36 + # 37 + # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this 38 + # method to pass it an input string from the user which can be a DID or handle, without having to 39 + # check which one it is. 40 + # 41 + # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string 42 + # @return [DID, nil] resolved DID if found, nil otherwise 43 + 21 44 def resolve_handle(handle) 22 45 if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP 23 46 return DID.new(handle) ··· 36 59 end 37 60 end 38 61 62 + # Tries to resolve a handle into DID using the DNS TXT method. 63 + # 64 + # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is 65 + # a correct DID string. 66 + # 67 + # @param domain [String] a domain handle to look up 68 + # @return [String, nil] resolved DID if found, nil otherwise 69 + 39 70 def resolve_handle_by_dns(domain) 40 71 dns_records = Resolv::DNS.open(resolv_options) do |d| 41 72 d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) ··· 50 81 nil 51 82 end 52 83 84 + # Tries to resolve a handle into DID using the HTTP .well-known method. 85 + # 86 + # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns 87 + # a text file that contains a correct DID string. 88 + # 89 + # @param domain [String] a domain handle to look up 90 + # @return [String, nil] resolved DID if found, nil otherwise 91 + 53 92 def resolve_handle_by_well_known(domain) 54 93 url = "https://#{domain}/.well-known/atproto-did" 55 94 response = get_response(url, @request_options) ··· 77 116 text = text.strip 78 117 text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil 79 118 end 119 + 120 + # Resolve a DID to a DID document. 121 + # 122 + # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either 123 + # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs. 124 + # 125 + # @param did [String, DID] DID string or object 126 + # @return [Document] resolved DID document 127 + # @raise [APIError] if an incorrect response is returned 80 128 81 129 def resolve_did(did) 82 130 did = DID.new(did) if did.is_a?(String) ··· 94 142 Document.new(did, json) 95 143 end 96 144 145 + # Returns the first verified handle assigned to the given DID. 146 + # 147 + # Looks up the domain handles assigned to the DID in the DID document, checks if they are 148 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 149 + # the first handle that validates correctly, or nil if none matches. 150 + # 151 + # @param subject [String, DID, Document] a DID or its DID document 152 + # @return [String, nil] verified handle domain, if found 153 + 97 154 def get_verified_handle(subject) 98 155 document = subject.is_a?(Document) ? subject : resolve_did(subject) 99 156 100 157 first_verified_handle(document.did, document.handles) 101 158 end 159 + 160 + # Returns the first handle from the list that resolves back to the given DID. 161 + # 162 + # @param did [DID, String] DID to verify the handles against 163 + # @param handles [Array<String>] handles to check 164 + # @return [String, nil] a verified handle, if found 102 165 103 166 def first_verified_handle(did, handles) 104 167 handles.detect { |h| resolve_handle(h) == did.to_s }
+22 -1
lib/didkit/service_record.rb
··· 2 2 require_relative 'errors' 3 3 4 4 module DIDKit 5 + 6 + # A parsed service record from either a DID document's `service` field or a PLC directory 7 + # operation's `services` field. 8 + 5 9 class ServiceRecord 6 - attr_reader :key, :type, :endpoint 10 + 11 + # Returns the service's identifier (without `#`), like "atproto_pds". 12 + # @return [String] service's identifier 13 + attr_reader :key 14 + 15 + # Returns the service's type field, like "AtprotoPersonalDataServer". 16 + # @return [String] service's type 17 + attr_reader :type 18 + 19 + # @return [String] service's endpoint URL 20 + attr_reader :endpoint 21 + 22 + # Create a service record from DID document fields. 23 + # 24 + # @param key [String] service identifier (without `#`) 25 + # @param type [String] service type 26 + # @param endpoint [String] service endpoint URL 27 + # @raise [FormatError] when the endpoint is not a valid URI 7 28 8 29 def initialize(key, type, endpoint) 9 30 begin
+40
lib/didkit/services.rb
··· 1 1 require 'uri' 2 2 3 3 module DIDKit 4 + 5 + # 6 + # @api private 7 + # 8 + 4 9 module Services 10 + 11 + # Finds a service entry matching the given key and type. 12 + # 13 + # @api public 14 + # @param key [String] service key in the DID document 15 + # @param type [String] service type identifier 16 + # @return [ServiceRecord, nil] matching service record, if found 17 + 5 18 def get_service(key, type) 6 19 @services&.detect { |s| s.key == key && s.type == type } 7 20 end 8 21 22 + # Returns the PDS service endpoint, if present. 23 + # 24 + # If the DID has an `#atproto_pds` service declared in its `service` section, 25 + # returns the URL in its `serviceEndpoint` field. In other words, this is the URL 26 + # of the PDS assigned to a given user, which stores the user's account and repo. 27 + # 28 + # @api public 29 + # @return [String, nil] PDS service endpoint URL 30 + 9 31 def pds_endpoint 10 32 @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint 11 33 end 34 + 35 + # Returns the labeler service endpoint, if present. 36 + # 37 + # If the DID has an `#atproto_labeler` service declared in its `service` section, 38 + # returns the URL in its `serviceEndpoint` field. 39 + # 40 + # @api public 41 + # @return [String, nil] labeler service endpoint URL 12 42 13 43 def labeler_endpoint 14 44 @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint 15 45 end 16 46 47 + # Returns the hostname of the PDS service, if present. 48 + # 49 + # @api public 50 + # @return [String, nil] hostname of the PDS endpoint URL 51 + 17 52 def pds_host 18 53 pds_endpoint&.then { |x| URI(x).host } 19 54 end 55 + 56 + # Returns the hostname of the labeler service, if present. 57 + # 58 + # @api public 59 + # @return [String, nil] hostname of the labeler endpoint URL 20 60 21 61 def labeler_host 22 62 labeler_endpoint&.then { |x| URI(x).host }