···11+## [0.3.1] - 2025-12-19
22+33+- allow passing a DID string or object to `#resolve_handle` and just return that DID โ so you can have a script that accepts either a handle or a DID, and passes the input to `DID.resolve_handle` without checking which one it is
44+- allow passing another DID object to `DID.new` and return a copy of that DID
55+- parse `seq` field in `PLCOperation` if included and expose it as a property
66+- fixed some errors on Rubies older than 3.2 due to missing `filter_map` and `URI#origin`
77+- `PLCOperation` verifies if the argument is a `Hash`
88+19## [0.3.0] - 2025-12-15
210311Breaking changes:
···11The zlib License
2233-Copyright (c) 2025 Jakub Suder
33+Copyright (c) 2026 Jakub Suder
4455This software is provided 'as-is', without any express or implied
66warranty. In no event will the authors be held liable for any damages
+8-4
README.md
···13131414## Installation
15151616-From the command line:
1616+To use DIDKit, you need a reasonably new version of Ruby โ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
17171818- gem install didkit
1818+To install the gem, run in the command line:
19192020-Or, add this to your `Gemfile`:
2020+ [sudo] gem install didkit
2121+2222+Or add this to your app's `Gemfile`:
21232224 gem 'didkit', '~> 0.3'
2325···106108107109## Credits
108110109109-Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
111111+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
110112111113The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
114114+115115+Bug reports and pull requests are welcome ๐
···66require_relative 'resolver'
7788module DIDKit
99+1010+ #
1111+ # Represents a DID identifier (account on the ATProto network). This class serves as an entry
1212+ # point to various lookup helpers. For convenience it can also be accessed as just `DID` without
1313+ # the `DIDKit::` prefix.
1414+ #
1515+ # @example Resolving a handle
1616+ # did = DID.resolve_handle('bsky.app')
1717+ #
1818+919 class DID
2020+ GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
2121+1022 include Requests
11232424+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
2525+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
2626+ #
2727+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
2828+ # method to pass it an input string from the user which can be a DID or handle, without having to
2929+ # check which one it is.
3030+ #
3131+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
3232+ # @return [DID, nil] resolved DID if found, nil otherwise
3333+1234 def self.resolve_handle(handle)
1335 Resolver.new.resolve_handle(handle)
1436 end
15371616- attr_reader :type, :did, :resolved_by
3838+ # @return [Symbol] DID type (`:plc` or `:web`)
3939+ attr_reader :type
4040+4141+ # @return [String] DID identifier string
4242+ attr_reader :did
4343+4444+ # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods
4545+ attr_reader :resolved_by
4646+4747+ alias to_s did
4848+4949+5050+ # Create a DID object from a DID string.
5151+ #
5252+ # @param did [String, DID] DID string or another DID object
5353+ # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`)
5454+ # @raise [DIDError] when the DID format or type is invalid
17551856 def initialize(did, resolved_by = nil)
1919- if did =~ /^did\:(\w+)\:/
5757+ if did.is_a?(DID)
5858+ did = did.to_s
5959+ end
6060+6161+ if did =~ GENERIC_REGEXP
2062 @did = did
2121- @type = $1.to_sym
6363+ @type = did.split(':')[1].to_sym
2264 else
2365 raise DIDError.new("Invalid DID format")
2466 end
···3072 @resolved_by = resolved_by
3173 end
32743333- alias to_s did
7575+ # Returns or looks up the DID document with the DID's identity details from an appropriate source.
7676+ # This method caches the document in a local variable if it's called again.
7777+ #
7878+ # @return [Document] resolved DID document
34793580 def document
3681 @document ||= get_document
3782 end
38838484+ # Looks up the DID document with the DID's identity details from an appropriate source.
8585+ # @return [Document] resolved DID document
8686+3987 def get_document
4088 Resolver.new.resolve_did(self)
4189 end
9090+9191+ # Returns the first verified handle assigned to this DID.
9292+ #
9393+ # Looks up the domain handles assigned to this DID in its DID document, checks if they are
9494+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
9595+ # the first handle that validates correctly, or nil if none matches.
9696+ #
9797+ # @return [String, nil] verified handle domain, if found
42984399 def get_verified_handle
44100 Resolver.new.get_verified_handle(document)
45101 end
46102103103+ # Fetches the PLC audit log (list of all previous operations) for a did:plc DID.
104104+ #
105105+ # @return [Array<PLCOperation>] list of PLC operations in the audit log
106106+ # @raise [DIDError] when the DID is not a did:plc
107107+47108 def get_audit_log
48109 if @type == :plc
49110 PLCImporter.new.fetch_audit_log(self)
···52113 end
53114 end
54115116116+ # Returns the domain portion of a did:web identifier.
117117+ #
118118+ # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc
119119+55120 def web_domain
56121 did.gsub(/^did\:web\:/, '') if type == :web
57122 end
58123124124+ # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint.
125125+ #
126126+ # @param request_options [Hash] request options to override
127127+ # @option request_options [Integer] :timeout request timeout (default: 15)
128128+ # @option request_options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
129129+ #
130130+ # @return [Symbol, nil] `:active`, or returned inactive status, or `nil` if account is not found
131131+ # @raise [APIError] when the response is invalid
132132+59133 def account_status(request_options = {})
60134 doc = self.document
61135 return nil if doc.pds_endpoint.nil?
621366363- pds_host = URI(doc.pds_endpoint).origin
137137+ pds_host = uri_origin(doc.pds_endpoint)
64138 url = URI("#{pds_host}/xrpc/com.atproto.sync.getRepoStatus")
65139 url.query = URI.encode_www_form(:did => @did)
66140···85159 end
86160 end
87161162162+ # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint.
163163+ # This is a helper which calls the {#account_status} method and checks if the status is `:active`.
164164+ #
165165+ # @return [Boolean] true if the returned status is active
166166+ # @raise [APIError] when the response is invalid
167167+88168 def account_active?
89169 account_status == :active
90170 end
91171172172+ # Checks if the account exists its own PDS, using the `getRepoStatus` endpoint.
173173+ # This is a helper which calls the {#account_status} method and checks if the repo is found at all.
174174+ #
175175+ # @return [Boolean] true if the returned status is valid, false if repo is not found
176176+ # @raise [APIError] when the response is invalid
177177+92178 def account_exists?
93179 account_status != nil
94180 end
181181+182182+ # Compares the DID to another DID object or string.
183183+ #
184184+ # @param other [DID, String] other DID to compare with
185185+ # @return [Boolean] true if it's the same DID
9518696187 def ==(other)
97188 if other.is_a?(String)
+58-17
lib/didkit/document.rb
···11require_relative 'at_handles'
22+require_relative 'errors'
23require_relative 'resolver'
34require_relative 'service_record'
45require_relative 'services'
5667module DIDKit
88+99+ #
1010+ # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain.
1111+ #
1212+ # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object.
1313+ #
1414+715 class Document
88- class FormatError < StandardError
99- end
1010-1116 include AtHandles
1217 include Services
13181414- attr_reader :json, :did, :handles, :services
1919+ # @return [Hash] the complete JSON data of the DID document
2020+ attr_reader :json
2121+2222+ # @return [DID] the DID that this document describes
2323+ attr_reader :did
2424+2525+ # Returns a list of handles assigned to this DID in its DID document.
2626+ #
2727+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
2828+ # Use {#get_verified_handle} to find a handle that is correctly verified.
2929+ #
3030+ # @return [Array<String>]
3131+ attr_reader :handles
3232+3333+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
3434+ attr_reader :services
3535+3636+ # Creates a DID document object.
3737+ #
3838+ # @param did [DID] DID object
3939+ # @param json [Hash] DID document JSON
4040+ # @raise [FormatError] when required fields are missing or invalid.
15411642 def initialize(did, json)
1743 raise FormatError, "Missing id field" if json['id'].nil?
···2147 @did = did
2248 @json = json
23492424- if service = json['service']
2525- raise FormatError, "Invalid service data" unless service.is_a?(Array) && service.all? { |x| x.is_a?(Hash) }
2626-2727- @services = service.filter_map { |x|
2828- id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
2929- next unless id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
3030-3131- ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
3232- }
3333- else
3434- @services = []
3535- end
3636-5050+ @services = parse_services(json['service'] || [])
3751 @handles = parse_also_known_as(json['alsoKnownAs'] || [])
3852 end
39535454+ # Returns the first verified handle assigned to the DID.
5555+ #
5656+ # Looks up the domain handles assigned to this DID in the DID document, checks if they are
5757+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
5858+ # the first handle that validates correctly, or nil if none matches.
5959+ #
6060+ # @return [String, nil] verified handle domain, if found
6161+4062 def get_verified_handle
4163 Resolver.new.get_verified_handle(self)
6464+ end
6565+6666+6767+ private
6868+6969+ def parse_services(service_data)
7070+ raise FormatError, "Invalid service data" unless service_data.is_a?(Array) && service_data.all? { |x| x.is_a?(Hash) }
7171+7272+ services = []
7373+7474+ service_data.each do |x|
7575+ id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
7676+7777+ if id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
7878+ services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
7979+ end
8080+ end
8181+8282+ services
4283 end
4384 end
4485end
+20-2
lib/didkit/errors.rb
···11module DIDKit
22- class DIDError < StandardError
33- end
4233+ #
44+ # Raised when an HTTP request returns a response with an error status.
55+ #
56 class APIError < StandardError
77+88+ # @return [Net::HTTPResponse] the returned HTTP response
69 attr_reader :response
7101111+ # @param response [Net::HTTPResponse] the returned HTTP response
812 def initialize(response)
913 @response = response
1014 super("APIError: #{response}")
1115 end
12161717+ # @return [Integer] HTTP status code
1318 def status
1419 response.code.to_i
1520 end
16212222+ # @return [String] HTTP response body
1723 def body
1824 response.body
1925 end
2626+ end
2727+2828+ #
2929+ # Raised when a string is not a valid DID or not of the right type.
3030+ #
3131+ class DIDError < StandardError
3232+ end
3333+3434+ #
3535+ # Raised when the loaded data has some missing or invalid fields.
3636+ #
3737+ class FormatError < StandardError
2038 end
2139end
+48-5
lib/didkit/plc_operation.rb
···11require 'time'
2233require_relative 'at_handles'
44+require_relative 'errors'
45require_relative 'service_record'
56require_relative 'services'
6778module DIDKit
88- class PLCOperation
99- class FormatError < StandardError
1010- end
1191010+ #
1111+ # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory)
1212+ # (e.g. changing assigned handles or migrating to a different PDS).
1313+ #
1414+1515+ class PLCOperation
1216 include AtHandles
1317 include Services
14181515- attr_reader :json, :did, :cid, :created_at, :type, :handles, :services
1919+ # @return [Hash] the JSON from which the operation is parsed
2020+ attr_reader :json
2121+2222+ # @return [String] the DID which the operation concerns
2323+ attr_reader :did
2424+2525+ # @return [String] CID (Content Identifier) of the operation
2626+ attr_reader :cid
2727+2828+ # Returns a sequential number of the operation (only used in the new export API).
2929+ # @return [Integer, nil] sequential number of the operation
3030+ attr_reader :seq
3131+3232+ # @return [Time] time when the operation was created
3333+ attr_reader :created_at
3434+3535+ # Returns the `type` field of the operation (usually `"plc_operation"`).
3636+ # @return [String] the operation type
3737+ attr_reader :type
3838+3939+ # Returns a list of handles assigned to the DID in this operation.
4040+ #
4141+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
4242+ # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is
4343+ # correctly verified.
4444+ #
4545+ # @return [Array<String>]
4646+ attr_reader :handles
4747+4848+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
4949+ attr_reader :services
5050+5151+5252+ # Creates a PLCOperation object.
5353+ #
5454+ # @param json [Hash] operation JSON
5555+ # @raise [FormatError] when required fields are missing or invalid
16561757 def initialize(json)
1858 @json = json
5959+ raise FormatError, "Expected argument to be a Hash, got a #{json.class}" unless @json.is_a?(Hash)
6060+6161+ @seq = json['seq']
1962 @did = json['did']
2063 raise FormatError, "Missing DID: #{json}" if @did.nil?
2121- raise FormatError, "Invalid DID: #{@did}" unless @did.is_a?(String) && @did.start_with?('did:')
6464+ raise FormatError, "Invalid DID: #{@did.inspect}" unless @did.is_a?(String) && @did.start_with?('did:')
22652366 @cid = json['cid']
2467 raise FormatError, "Missing CID: #{json}" if @cid.nil?
···66require_relative 'requests'
7788module DIDKit
99+1010+ #
1111+ # A class which manages resolving of handles to DIDs and DIDs to DID documents.
1212+ #
1313+914 class Resolver
1515+ # These TLDs are not allowed in ATProto handles, so the resolver returns nil for them
1616+ # without trying to look them up.
1017 RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
11181219 include Requests
13202121+ # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups
1422 attr_accessor :nameserver
2323+2424+ # @param options [Hash] resolver options
2525+ # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs)
2626+ # @option options [Integer] :timeout request timeout in seconds (default: 15)
2727+ # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
15281629 def initialize(options = {})
1730 @nameserver = options[:nameserver]
1831 @request_options = options.slice(:timeout, :max_redirects)
1932 end
20333434+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
3535+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
3636+ #
3737+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
3838+ # method to pass it an input string from the user which can be a DID or handle, without having to
3939+ # check which one it is.
4040+ #
4141+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
4242+ # @return [DID, nil] resolved DID if found, nil otherwise
4343+2144 def resolve_handle(handle)
4545+ if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP
4646+ return DID.new(handle)
4747+ end
4848+2249 domain = handle.gsub(/^@/, '')
23502451 return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
···3259 end
3360 end
34616262+ # Tries to resolve a handle into DID using the DNS TXT method.
6363+ #
6464+ # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is
6565+ # a correct DID string.
6666+ #
6767+ # @param domain [String] a domain handle to look up
6868+ # @return [String, nil] resolved DID if found, nil otherwise
6969+3570 def resolve_handle_by_dns(domain)
3671 dns_records = Resolv::DNS.open(resolv_options) do |d|
3772 d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
···4681 nil
4782 end
48838484+ # Tries to resolve a handle into DID using the HTTP .well-known method.
8585+ #
8686+ # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns
8787+ # a text file that contains a correct DID string.
8888+ #
8989+ # @param domain [String] a domain handle to look up
9090+ # @return [String, nil] resolved DID if found, nil otherwise
9191+4992 def resolve_handle_by_well_known(domain)
5093 url = "https://#{domain}/.well-known/atproto-did"
5194 response = get_response(url, @request_options)
···59102 nil
60103 end
61104105105+ # Resolve a DID to a DID document.
106106+ #
107107+ # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
108108+ # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs.
109109+ #
110110+ # @param did [String, DID] DID string or object
111111+ # @return [Document] resolved DID document
112112+ # @raise [APIError] if an incorrect response is returned
113113+114114+ def resolve_did(did)
115115+ did = DID.new(did) if did.is_a?(String)
116116+117117+ did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
118118+ end
119119+120120+ # Returns the first verified handle assigned to the given DID.
121121+ #
122122+ # Looks up the domain handles assigned to the DID in the DID document, checks if they are
123123+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
124124+ # the first handle that validates correctly, or nil if none matches.
125125+ #
126126+ # @param subject [String, DID, Document] a DID or its DID document
127127+ # @return [String, nil] verified handle domain, if found
128128+129129+ def get_verified_handle(subject)
130130+ document = subject.is_a?(Document) ? subject : resolve_did(subject)
131131+132132+ first_verified_handle(document.did, document.handles)
133133+ end
134134+135135+ # Returns the first handle from the list that resolves back to the given DID.
136136+ #
137137+ # @param did [DID, String] DID to verify the handles against
138138+ # @param handles [Array<String>] handles to check
139139+ # @return [String, nil] a verified handle, if found
140140+141141+ def first_verified_handle(did, handles)
142142+ handles.detect { |h| resolve_handle(h) == did.to_s }
143143+ end
144144+145145+146146+ private
147147+62148 def resolv_options
63149 options = Resolv::DNS::Config.default_config_hash.dup
64150 options[:nameserver] = nameserver if nameserver
···7115772158 def parse_did_from_well_known(text)
73159 text = text.strip
7474- text.lines.length == 1 && text =~ /\Adid\:\w+\:.*\z/ ? text : nil
7575- end
7676-7777- def resolve_did(did)
7878- did = DID.new(did) if did.is_a?(String)
7979-8080- did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
160160+ text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil
81161 end
8216283163 def resolve_did_plc(did)
···88168 def resolve_did_web(did)
89169 json = get_json("https://#{did.web_domain}/.well-known/did.json")
90170 Document.new(did, json)
9191- end
9292-9393- def get_verified_handle(subject)
9494- document = subject.is_a?(Document) ? subject : resolve_did(subject)
9595-9696- first_verified_handle(document.did, document.handles)
9797- end
9898-9999- def first_verified_handle(did, handles)
100100- handles.detect { |h| resolve_handle(h) == did.to_s }
101171 end
102172 end
103173end
+21-3
lib/didkit/service_record.rb
···22require_relative 'errors'
3344module DIDKit
55+66+ # A parsed service record from either a DID document's `service` field or a PLC directory
77+ # operation's `services` field.
88+59 class ServiceRecord
66- class FormatError < StandardError
77- end
81099- attr_reader :key, :type, :endpoint
1111+ # Returns the service's identifier (without `#`), like "atproto_pds".
1212+ # @return [String] service's identifier
1313+ attr_reader :key
1414+1515+ # Returns the service's type field, like "AtprotoPersonalDataServer".
1616+ # @return [String] service's type
1717+ attr_reader :type
1818+1919+ # @return [String] service's endpoint URL
2020+ attr_reader :endpoint
2121+2222+ # Create a service record from DID document fields.
2323+ #
2424+ # @param key [String] service identifier (without `#`)
2525+ # @param type [String] service type
2626+ # @param endpoint [String] service endpoint URL
2727+ # @raise [FormatError] when the endpoint is not a valid URI
10281129 def initialize(key, type, endpoint)
1230 begin
+40
lib/didkit/services.rb
···11require 'uri'
2233module DIDKit
44+55+ #
66+ # @api private
77+ #
88+49 module Services
1010+1111+ # Finds a service entry matching the given key and type.
1212+ #
1313+ # @api public
1414+ # @param key [String] service key in the DID document
1515+ # @param type [String] service type identifier
1616+ # @return [ServiceRecord, nil] matching service record, if found
1717+518 def get_service(key, type)
619 @services&.detect { |s| s.key == key && s.type == type }
720 end
8212222+ # Returns the PDS service endpoint, if present.
2323+ #
2424+ # If the DID has an `#atproto_pds` service declared in its `service` section,
2525+ # returns the URL in its `serviceEndpoint` field. In other words, this is the URL
2626+ # of the PDS assigned to a given user, which stores the user's account and repo.
2727+ #
2828+ # @api public
2929+ # @return [String, nil] PDS service endpoint URL
3030+931 def pds_endpoint
1032 @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
1133 end
3434+3535+ # Returns the labeler service endpoint, if present.
3636+ #
3737+ # If the DID has an `#atproto_labeler` service declared in its `service` section,
3838+ # returns the URL in its `serviceEndpoint` field.
3939+ #
4040+ # @api public
4141+ # @return [String, nil] labeler service endpoint URL
12421343 def labeler_endpoint
1444 @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
1545 end
16464747+ # Returns the hostname of the PDS service, if present.
4848+ #
4949+ # @api public
5050+ # @return [String, nil] hostname of the PDS endpoint URL
5151+1752 def pds_host
1853 pds_endpoint&.then { |x| URI(x).host }
1954 end
5555+5656+ # Returns the hostname of the labeler service, if present.
5757+ #
5858+ # @api public
5959+ # @return [String, nil] hostname of the labeler endpoint URL
20602161 def labeler_host
2262 labeler_endpoint&.then { |x| URI(x).host }
+1-1
lib/didkit/version.rb
···11# frozen_string_literal: true
2233module DIDKit
44- VERSION = "0.3.0"
44+ VERSION = "0.3.1"
55end
+238
spec/did_spec.rb
···11+describe DIDKit::DID do
22+ subject { described_class }
33+44+ let(:plc_did) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
55+ let(:web_did) { 'did:web:taylorswift.com' }
66+77+ describe '#initialize' do
88+ context 'with a valid did:plc' do
99+ it 'should return an initialized DID object' do
1010+ did = subject.new(plc_did)
1111+1212+ did.should be_a(DIDKit::DID)
1313+ did.type.should == :plc
1414+ did.did.should be_a(String)
1515+ did.did.should == plc_did
1616+ did.resolved_by.should be_nil
1717+ end
1818+ end
1919+2020+ context 'with a valid did:web' do
2121+ it 'should return an initialized DID object' do
2222+ did = subject.new(web_did)
2323+2424+ did.should be_a(DIDKit::DID)
2525+ did.type.should == :web
2626+ did.did.should be_a(String)
2727+ did.did.should == web_did
2828+ did.resolved_by.should be_nil
2929+ end
3030+ end
3131+3232+ context 'with another DID object' do
3333+ it 'should create a copy of the DID' do
3434+ other = subject.new(plc_did)
3535+ did = subject.new(other)
3636+3737+ did.did.should == plc_did
3838+ did.type.should == :plc
3939+ did.equal?(other).should == false
4040+ end
4141+ end
4242+4343+ context 'with a string that is not a DID' do
4444+ it 'should raise an error' do
4545+ expect {
4646+ subject.new('not-a-did')
4747+ }.to raise_error(DIDKit::DIDError)
4848+ end
4949+ end
5050+5151+ context 'when an unrecognized did: type' do
5252+ it 'should raise an error' do
5353+ expect {
5454+ subject.new('did:example:123')
5555+ }.to raise_error(DIDKit::DIDError)
5656+ end
5757+ end
5858+ end
5959+6060+ describe '#web_domain' do
6161+ context 'for a did:web' do
6262+ it 'should return the domain part' do
6363+ did = subject.new('did:web:site.example.com')
6464+6565+ did.web_domain.should == 'site.example.com'
6666+ end
6767+ end
6868+6969+ context 'for a did:plc' do
7070+ it 'should return nil' do
7171+ did = subject.new('did:plc:yk4dd2qkboz2yv6tpubpc6co')
7272+7373+ did.web_domain.should be_nil
7474+ end
7575+ end
7676+ end
7777+7878+ describe '#==' do
7979+ let(:did_string) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
8080+ let(:other_string) { 'did:plc:oio4hkxaop4ao4wz2pp3f4cr' }
8181+8282+ let(:did) { subject.new(did_string) }
8383+ let(:other) { subject.new(other_string) }
8484+8585+ context 'given a DID string' do
8686+ it 'should compare its string value to the other DID' do
8787+ did.should == did_string
8888+ did.should_not == other_string
8989+ end
9090+ end
9191+9292+ context 'given another DID object' do
9393+ it "should compare its string value to the other DID's string value" do
9494+ copy = subject.new(did_string)
9595+9696+ did.should == copy
9797+ did.should_not == other
9898+ end
9999+ end
100100+101101+ context 'given something that is not a DID' do
102102+ it 'should return false' do
103103+ did.should_not == :didplc
104104+ did.should_not == [did_string]
105105+ end
106106+ end
107107+ end
108108+109109+ describe '#to_s' do
110110+ it "should return the DID's string value" do
111111+ did = subject.new(plc_did)
112112+113113+ did.to_s.should be_a(String)
114114+ did.to_s.should == plc_did
115115+ end
116116+ end
117117+118118+ describe 'account status' do
119119+ let(:document) { stub(:pds_endpoint => 'https://pds.ruby.space') }
120120+ let(:did) { subject.new(plc_did) }
121121+122122+ before do
123123+ did.stubs(:document).returns(document)
124124+125125+ stub_request(:get, 'https://pds.ruby.space/xrpc/com.atproto.sync.getRepoStatus')
126126+ .with(query: { did: plc_did })
127127+ .to_return(http_response) if defined?(http_response)
128128+ end
129129+130130+ context 'when repo is active' do
131131+ let(:http_response) {
132132+ { body: { active: true }.to_json, headers: { 'Content-Type' => 'application/json' }}
133133+ }
134134+135135+ it 'should report active account state' do
136136+ did.account_status.should == :active
137137+ did.account_active?.should == true
138138+ did.account_exists?.should == true
139139+ end
140140+ end
141141+142142+ context 'when repo is inactive' do
143143+ let(:http_response) {
144144+ { body: { active: false, status: 'takendown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
145145+ }
146146+147147+ it 'should report an inactive existing account' do
148148+ did.account_status.should == :takendown
149149+ did.account_active?.should == false
150150+ did.account_exists?.should == true
151151+ end
152152+ end
153153+154154+ context 'when repo is not found' do
155155+ let(:http_response) {
156156+ { status: 400, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
157157+ }
158158+159159+ it 'should return nil status and report the account as missing' do
160160+ did.account_status.should be_nil
161161+ did.account_active?.should == false
162162+ did.account_exists?.should == false
163163+ end
164164+ end
165165+166166+ context 'when the document has no pds endpoint' do
167167+ before do
168168+ did.stubs(:document).returns(stub(:pds_endpoint => nil))
169169+ end
170170+171171+ it 'should return nil status and report the account as missing' do
172172+ did.account_status.should be_nil
173173+ did.account_active?.should == false
174174+ did.account_exists?.should == false
175175+ end
176176+ end
177177+178178+ context 'when active field is not set' do
179179+ let(:http_response) {
180180+ { body: { active: nil, status: 'unknown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
181181+ }
182182+183183+ it 'should raise APIError' do
184184+ expect { did.account_status }.to raise_error(DIDKit::APIError)
185185+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
186186+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
187187+ end
188188+ end
189189+190190+ context 'when active is false but status is not set' do
191191+ let(:http_response) {
192192+ { body: { active: false, status: nil }.to_json, headers: { 'Content-Type' => 'application/json' }}
193193+ }
194194+195195+ it 'should raise APIError' do
196196+ expect { did.account_status }.to raise_error(DIDKit::APIError)
197197+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
198198+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
199199+ end
200200+ end
201201+202202+ context 'when an error different than RepoNotFound is returned' do
203203+ let(:http_response) {
204204+ { status: 400, body: { error: 'UserIsJerry' }.to_json, headers: { 'Content-Type' => 'application/json' }}
205205+ }
206206+207207+ it 'should raise APIError' do
208208+ expect { did.account_status }.to raise_error(DIDKit::APIError)
209209+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
210210+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
211211+ end
212212+ end
213213+214214+ context 'when the response is not application/json' do
215215+ let(:http_response) {
216216+ { status: 400, body: 'error', headers: { 'Content-Type' => 'text/html' }}
217217+ }
218218+219219+ it 'should raise APIError' do
220220+ expect { did.account_status }.to raise_error(DIDKit::APIError)
221221+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
222222+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
223223+ end
224224+ end
225225+226226+ context 'when the response is not 200 or 400' do
227227+ let(:http_response) {
228228+ { status: 500, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
229229+ }
230230+231231+ it 'should raise APIError' do
232232+ expect { did.account_status }.to raise_error(DIDKit::APIError)
233233+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
234234+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
235235+ end
236236+ end
237237+ end
238238+end
···11+describe DIDKit::Document do
22+ subject { described_class }
33+44+ let(:did) { DID.new('did:plc:yk4dd2qkboz2yv6tpubpc6co') }
55+ let(:base_json) { load_did_json('dholms.json') }
66+77+ describe '#initialize' do
88+ context 'with valid input' do
99+ let(:json) { base_json }
1010+1111+ it 'should return a Document object' do
1212+ doc = subject.new(did, json)
1313+1414+ doc.should be_a(DIDKit::Document)
1515+ doc.did.should == did
1616+ doc.json.should == json
1717+ end
1818+1919+ it 'should parse services from the JSON' do
2020+ doc = subject.new(did, json)
2121+2222+ doc.services.should be_an(Array)
2323+ doc.services.length.should == 1
2424+2525+ doc.services[0].should be_a(DIDKit::ServiceRecord)
2626+ doc.services[0].key.should == 'atproto_pds'
2727+ doc.services[0].type.should == 'AtprotoPersonalDataServer'
2828+ doc.services[0].endpoint.should == 'https://pds.dholms.xyz'
2929+ end
3030+3131+ it 'should parse handles from the JSON' do
3232+ doc = subject.new(did, json)
3333+3434+ doc.handles.should == ['dholms.xyz']
3535+ end
3636+ end
3737+3838+ context 'when id is missing' do
3939+ let(:json) { base_json.dup.tap { |h| h.delete('id') }}
4040+4141+ it 'should raise a format error' do
4242+ expect {
4343+ subject.new(did, json)
4444+ }.to raise_error(DIDKit::FormatError)
4545+ end
4646+ end
4747+4848+ context 'when id is not a string' do
4949+ let(:json) { base_json.merge('id' => 123) }
5050+5151+ it 'should raise a format error' do
5252+ expect {
5353+ subject.new(did, json)
5454+ }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when id does not match the DID' do
5959+ let(:json) { base_json.merge('id' => 'did:plc:notmatching') }
6060+6161+ it 'should raise a format error' do
6262+ expect {
6363+ subject.new(did, json)
6464+ }.to raise_error(DIDKit::FormatError)
6565+ end
6666+ end
6767+6868+ context 'when alsoKnownAs is not an array' do
6969+ let(:json) { base_json.merge('alsoKnownAs' => 'at://dholms.xyz') }
7070+7171+ it 'should raise an AtHandles format error' do
7272+ expect {
7373+ subject.new(did, json)
7474+ }.to raise_error(DIDKit::FormatError)
7575+ end
7676+ end
7777+7878+ context 'when alsoKnownAs elements are not strings' do
7979+ let(:json) { base_json.merge('alsoKnownAs' => [666]) }
8080+8181+ it 'should raise an AtHandles format error' do
8282+ expect {
8383+ subject.new(did, json)
8484+ }.to raise_error(DIDKit::FormatError)
8585+ end
8686+ end
8787+8888+ context 'when alsoKnownAs contains multiple handles' do
8989+ let(:json) {
9090+ base_json.merge('alsoKnownAs' => [
9191+ 'at://dholms.xyz',
9292+ 'https://example.com',
9393+ 'at://other.handle'
9494+ ])
9595+ }
9696+9797+ it 'should pick those starting with at:// and remove the prefixes' do
9898+ doc = subject.new(did, json)
9999+ doc.handles.should == ['dholms.xyz', 'other.handle']
100100+ end
101101+ end
102102+103103+ context 'when service is not an array' do
104104+ let(:json) { base_json.merge('service' => 'not-an-array') }
105105+106106+ it 'should raise a format error' do
107107+ expect {
108108+ subject.new(did, json)
109109+ }.to raise_error(DIDKit::FormatError)
110110+ end
111111+ end
112112+113113+ context 'when service entries are not hashes' do
114114+ let(:json) { base_json.merge('service' => ['invalid']) }
115115+116116+ it 'should raise a format error' do
117117+ expect {
118118+ subject.new(did, json)
119119+ }.to raise_error(DIDKit::FormatError)
120120+ end
121121+ end
122122+123123+ context 'when service entries are partially valid' do
124124+ let(:services) {
125125+ [
126126+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
127127+ { 'id' => 'not_a_hash', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
128128+ { 'id' => '#wrong_type', 'type' => 123, 'serviceEndpoint' => 'https://pds.dholms.xyz' },
129129+ { 'id' => '#wrong_endpoint', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 123 },
130130+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
131131+ ]
132132+ }
133133+134134+ let(:json) { base_json.merge('service' => services) }
135135+136136+ it 'should only keep the valid records' do
137137+ doc = subject.new(did, json)
138138+139139+ doc.services.length.should == 2
140140+ doc.services.map(&:key).should == ['atproto_pds', 'lycan']
141141+ doc.services.map(&:type).should == ['AtprotoPersonalDataServer', 'LycanService']
142142+ doc.services.map(&:endpoint).should == ['https://pds.dholms.xyz', 'https://lycan.feeds.blue']
143143+ end
144144+ end
145145+ end
146146+147147+ describe 'service helpers' do
148148+ let(:service_json) {
149149+ base_json.merge('service' => [
150150+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
151151+ { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz' },
152152+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
153153+ ])
154154+ }
155155+156156+ describe '#pds_endpoint' do
157157+ it 'should return the endpoint of #atproto_pds' do
158158+ doc = subject.new(did, service_json)
159159+ doc.pds_endpoint.should == 'https://pds.dholms.xyz'
160160+ end
161161+ end
162162+163163+ describe '#pds_host' do
164164+ it 'should return the host part of #atproto_pds endpoint' do
165165+ doc = subject.new(did, service_json)
166166+ doc.pds_host.should == 'pds.dholms.xyz'
167167+ end
168168+ end
169169+170170+ describe '#labeler_endpoint' do
171171+ it 'should return the endpoint of #atproto_labeler' do
172172+ doc = subject.new(did, service_json)
173173+ doc.labeler_endpoint.should == 'https://labels.dholms.xyz'
174174+ end
175175+ end
176176+177177+ describe '#labeler_host' do
178178+ it 'should return the host part of #atproto_labeler endpoint' do
179179+ doc = subject.new(did, service_json)
180180+ doc.labeler_host.should == 'labels.dholms.xyz'
181181+ end
182182+ end
183183+184184+ describe '#get_service' do
185185+ it 'should fetch a service by key and type' do
186186+ doc = subject.new(did, service_json)
187187+188188+ lycan = doc.get_service('lycan', 'LycanService')
189189+ lycan.should_not be_nil
190190+ lycan.endpoint.should == 'https://lycan.feeds.blue'
191191+ end
192192+193193+ it 'should return nil if none of the services match' do
194194+ doc = subject.new(did, service_json)
195195+196196+ result = doc.get_service('lycan', 'AtprotoLabeler')
197197+ result.should be_nil
198198+199199+ result = doc.get_service('atproto_pds', 'PDS')
200200+ result.should be_nil
201201+202202+ result = doc.get_service('unknown', 'Test')
203203+ result.should be_nil
204204+ end
205205+ end
206206+207207+ it 'should expose the "labeller" aliases for endpoint and host' do
208208+ doc = subject.new(did, service_json)
209209+210210+ doc.labeller_endpoint.should == 'https://labels.dholms.xyz'
211211+ doc.labeller_host.should == 'labels.dholms.xyz'
212212+ end
213213+214214+ describe 'if there is no matching service' do
215215+ let(:service_json) {
216216+ base_json.merge('service' => [
217217+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
218218+ ])
219219+ }
220220+221221+ it 'should return nil from the relevant methods' do
222222+ doc = subject.new(did, service_json)
223223+224224+ doc.pds_endpoint.should be_nil
225225+ doc.pds_host.should be_nil
226226+ doc.labeller_endpoint.should be_nil
227227+ doc.labeller_host.should be_nil
228228+ doc.labeler_endpoint.should be_nil
229229+ doc.labeler_host.should be_nil
230230+ end
231231+ end
232232+ end
233233+end
+358
spec/plc_operation_spec.rb
···11+require 'time'
22+33+describe DIDKit::PLCOperation do
44+ subject { described_class }
55+66+ let(:base_json) { load_did_json('bnewbold_log.json').last }
77+88+ describe '#initialize' do
99+ context 'with a valid plc operation' do
1010+ let(:json) { base_json }
1111+1212+ it 'should return a PLCOperation with parsed data' do
1313+ op = subject.new(json)
1414+1515+ op.json.should == json
1616+ op.type.should == :plc_operation
1717+ op.did.should == 'did:plc:44ybard66vv44zksje25o7dz'
1818+ op.cid.should == 'bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m'
1919+ op.created_at.should be_a(Time)
2020+ op.created_at.should == Time.parse("2025-06-06T00:34:40.824Z")
2121+ op.handles.should == ['bnewbold.net']
2222+ op.services.map(&:key).should == ['atproto_pds']
2323+ end
2424+ end
2525+2626+ context 'when argument is not a hash' do
2727+ let(:json) { [base_json] }
2828+2929+ it 'should raise a format error' do
3030+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3131+ end
3232+ end
3333+3434+ context 'when did is missing' do
3535+ let(:json) { base_json.tap { |h| h.delete('did') }}
3636+3737+ it 'should raise a format error' do
3838+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3939+ end
4040+ end
4141+4242+ context 'when did is not a string' do
4343+ let(:json) { base_json.merge('did' => 123) }
4444+4545+ it 'should raise a format error' do
4646+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
4747+ end
4848+ end
4949+5050+ context "when did doesn't start with did:" do
5151+ let(:json) { base_json.merge('did' => 'foobar') }
5252+5353+ it 'should raise a format error' do
5454+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when cid is missing' do
5959+ let(:json) { base_json.tap { |h| h.delete('cid') }}
6060+6161+ it 'should raise a format error' do
6262+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
6363+ end
6464+ end
6565+6666+ context 'when cid is not a string' do
6767+ let(:json) { base_json.merge('cid' => 700) }
6868+6969+ it 'should raise a format error' do
7070+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7171+ end
7272+ end
7373+7474+ context 'when createdAt is missing' do
7575+ let(:json) { base_json.tap { |h| h.delete('createdAt') }}
7676+7777+ it 'should raise a format error' do
7878+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7979+ end
8080+ end
8181+8282+ context 'when createdAt is invalid' do
8383+ let(:json) { base_json.merge('createdAt' => 123) }
8484+8585+ it 'should raise a format error' do
8686+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
8787+ end
8888+ end
8989+9090+ context 'when operation block is missing' do
9191+ let(:json) { base_json.tap { |h| h.delete('operation') }}
9292+9393+ it 'should raise a format error' do
9494+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
9595+ end
9696+ end
9797+9898+ context 'when operation block is not a hash' do
9999+ let(:json) { base_json.merge('operation' => 'invalid') }
100100+101101+ it 'should raise a format error' do
102102+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
103103+ end
104104+ end
105105+106106+ context 'when operation type is missing' do
107107+ let(:json) { base_json.tap { |h| h['operation'].delete('type') }}
108108+109109+ it 'should raise a format error' do
110110+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
111111+ end
112112+ end
113113+114114+ context 'when operation type is not plc_operation' do
115115+ let(:json) { base_json.tap { |h| h['operation']['type'] = 'other' }}
116116+117117+ it 'should not raise an error' do
118118+ expect { subject.new(json) }.not_to raise_error
119119+ end
120120+121121+ it 'should return the operation type' do
122122+ op = subject.new(json)
123123+ op.type.should == :other
124124+ end
125125+126126+ it 'should not try to parse services' do
127127+ json['services'] = nil
128128+129129+ expect { subject.new(json) }.not_to raise_error
130130+ end
131131+132132+ it 'should return nil from services' do
133133+ op = subject.new(json)
134134+ op.services.should be_nil
135135+ end
136136+137137+ it 'should not try to parse handles' do
138138+ json['alsoKnownAs'] = nil
139139+140140+ expect { subject.new(json) }.not_to raise_error
141141+ end
142142+143143+ it 'should return nil from handles' do
144144+ op = subject.new(json)
145145+ op.handles.should be_nil
146146+ end
147147+ end
148148+149149+ context 'when alsoKnownAs is not an array' do
150150+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = 'at://dholms.xyz' }}
151151+152152+ it 'should raise an AtHandles format error' do
153153+ expect {
154154+ subject.new(json)
155155+ }.to raise_error(DIDKit::FormatError)
156156+ end
157157+ end
158158+159159+ context 'when alsoKnownAs elements are not strings' do
160160+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = [666] }}
161161+162162+ it 'should raise an AtHandles format error' do
163163+ expect {
164164+ subject.new(json)
165165+ }.to raise_error(DIDKit::FormatError)
166166+ end
167167+ end
168168+169169+ context 'when alsoKnownAs contains multiple handles' do
170170+ let(:json) {
171171+ base_json.tap { |h|
172172+ h['operation']['alsoKnownAs'] = [
173173+ 'at://dholms.xyz',
174174+ 'https://example.com',
175175+ 'at://other.handle'
176176+ ]
177177+ }
178178+ }
179179+180180+ it 'should pick those starting with at:// and remove the prefixes' do
181181+ op = subject.new(json)
182182+ op.handles.should == ['dholms.xyz', 'other.handle']
183183+ end
184184+ end
185185+186186+ context 'when services are missing' do
187187+ let(:json) { base_json.tap { |h| h['operation'].delete('services') }}
188188+189189+ it 'should raise a format error' do
190190+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
191191+ end
192192+ end
193193+194194+ context 'when services entry is not a hash' do
195195+ let(:json) {
196196+ base_json.tap { |h|
197197+ h['operation']['services'] = [
198198+ {
199199+ "id": "#atproto_pds",
200200+ "type": "AtprotoPersonalDataServer",
201201+ "serviceEndpoint": "https://pds.dholms.xyz"
202202+ }
203203+ ]
204204+ }
205205+ }
206206+207207+ it 'should raise a format error' do
208208+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
209209+ end
210210+ end
211211+212212+ context 'when a service entry is missing fields' do
213213+ let(:json) {
214214+ base_json.tap { |h|
215215+ h['operation']['services'] = {
216216+ "atproto_pds" => {
217217+ "endpoint" => "https://pds.dholms.xyz"
218218+ },
219219+ "atproto_labeler" => {
220220+ "type" => "AtprotoLabeler",
221221+ "endpoint" => "https://labeler.example.com"
222222+ }
223223+ }
224224+ }
225225+ }
226226+227227+ it 'should raise a format error' do
228228+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
229229+ end
230230+ end
231231+232232+ context 'when services are valid' do
233233+ let(:json) {
234234+ base_json.tap { |h|
235235+ h['operation']['services'] = {
236236+ "atproto_pds" => {
237237+ "type" => "AtprotoPersonalDataServer",
238238+ "endpoint" => "https://pds.dholms.xyz"
239239+ },
240240+ "atproto_labeler" => {
241241+ "type" => "AtprotoLabeler",
242242+ "endpoint" => "https://labeler.example.com"
243243+ },
244244+ "custom_service" => {
245245+ "type" => "OtherService",
246246+ "endpoint" => "https://custom.example.com"
247247+ }
248248+ }
249249+ }
250250+ }
251251+252252+ it 'should parse services into ServiceRecords' do
253253+ op = subject.new(json)
254254+255255+ op.services.length.should == 3
256256+ op.services.each { |s| s.should be_a(DIDKit::ServiceRecord) }
257257+258258+ pds, labeller, custom = op.services
259259+260260+ pds.type.should == 'AtprotoPersonalDataServer'
261261+ pds.endpoint.should == 'https://pds.dholms.xyz'
262262+263263+ labeller.type.should == 'AtprotoLabeler'
264264+ labeller.endpoint.should == 'https://labeler.example.com'
265265+266266+ custom.type.should == 'OtherService'
267267+ custom.endpoint.should == 'https://custom.example.com'
268268+ end
269269+270270+ it 'should allow fetching services by key + type' do
271271+ op = subject.new(json)
272272+273273+ custom = op.get_service('custom_service', 'OtherService')
274274+ custom.should be_a(DIDKit::ServiceRecord)
275275+ custom.endpoint.should == 'https://custom.example.com'
276276+ end
277277+278278+ describe '#pds_endpoint' do
279279+ it 'should return the endpoint of #atproto_pds' do
280280+ op = subject.new(json)
281281+ op.pds_endpoint.should == 'https://pds.dholms.xyz'
282282+ end
283283+ end
284284+285285+ describe '#pds_host' do
286286+ it 'should return the host part of #atproto_pds endpoint' do
287287+ op = subject.new(json)
288288+ op.pds_host.should == 'pds.dholms.xyz'
289289+ end
290290+ end
291291+292292+ describe '#labeler_endpoint' do
293293+ it 'should return the endpoint of #atproto_labeler' do
294294+ op = subject.new(json)
295295+ op.labeler_endpoint.should == 'https://labeler.example.com'
296296+ end
297297+ end
298298+299299+ describe '#labeler_host' do
300300+ it 'should return the host part of #atproto_labeler endpoint' do
301301+ op = subject.new(json)
302302+ op.labeler_host.should == 'labeler.example.com'
303303+ end
304304+ end
305305+306306+ it 'should expose the "labeller" aliases for endpoint and host' do
307307+ op = subject.new(json)
308308+309309+ op.labeller_endpoint.should == 'https://labeler.example.com'
310310+ op.labeller_host.should == 'labeler.example.com'
311311+ end
312312+ end
313313+314314+ context 'when services are valid but the specific ones are missing' do
315315+ let(:json) {
316316+ base_json.tap { |h|
317317+ h['operation']['services'] = {
318318+ "custom_service" => {
319319+ "type" => "CustomService",
320320+ "endpoint" => "https://custom.example.com"
321321+ }
322322+ }
323323+ }
324324+ }
325325+326326+ it 'should parse service records' do
327327+ op = subject.new(json)
328328+ op.services.length.should == 1
329329+ end
330330+331331+ describe '#get_service' do
332332+ it 'should return nil' do
333333+ op = subject.new(json)
334334+ other = op.get_service('other_service', 'OtherService')
335335+ other.should be_nil
336336+ end
337337+ end
338338+339339+ describe '#pds_endpoint' do
340340+ it 'should return nil' do
341341+ op = subject.new(json)
342342+ op.pds_endpoint.should be_nil
343343+ op.pds_host.should be_nil
344344+ end
345345+ end
346346+347347+ describe '#labeler_endpoint' do
348348+ it 'should return nil' do
349349+ op = subject.new(json)
350350+ op.labeler_endpoint.should be_nil
351351+ op.labeller_endpoint.should be_nil
352352+ op.labeler_host.should be_nil
353353+ op.labeller_host.should be_nil
354354+ end
355355+ end
356356+ end
357357+ end
358358+end
+179
spec/resolver_spec.rb
···11+describe DIDKit::Resolver do
22+ let(:sample_did) { 'did:plc:qhfo22pezo44fa3243z2h4ny' }
33+44+ describe '#resolve_handle' do
55+ context 'when handle resolves via HTTP' do
66+ before do
77+ Resolv::DNS.stubs(:open).returns([])
88+ end
99+1010+ let(:handle) { 'barackobama.bsky.social' }
1111+1212+ it 'should return a matching DID' do
1313+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
1414+ .to_return(body: sample_did)
1515+1616+ result = subject.resolve_handle(handle)
1717+1818+ result.should_not be_nil
1919+ result.should be_a(DID)
2020+ result.to_s.should == sample_did
2121+ result.resolved_by.should == :http
2222+ end
2323+2424+ it 'should check DNS first' do
2525+ Resolv::DNS.expects(:open).returns([])
2626+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
2727+ .to_return(body: sample_did)
2828+2929+ result = subject.resolve_handle(handle)
3030+ end
3131+3232+ context 'when HTTP returns invalid text' do
3333+ it 'should return nil' do
3434+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
3535+ .to_return(body: "Welcome to nginx!")
3636+3737+ result = subject.resolve_handle(handle)
3838+ result.should be_nil
3939+ end
4040+ end
4141+4242+ context 'when HTTP returns bad response' do
4343+ it 'should return nil' do
4444+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
4545+ .to_return(status: 400, body: sample_did)
4646+4747+ result = subject.resolve_handle(handle)
4848+ result.should be_nil
4949+ end
5050+ end
5151+5252+ context 'when HTTP throws an exception' do
5353+ it 'should catch it and return nil' do
5454+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
5555+ .to_raise(Errno::ETIMEDOUT)
5656+5757+ result = 0
5858+5959+ expect {
6060+ result = subject.resolve_handle(handle)
6161+ }.to_not raise_error
6262+6363+ result.should be_nil
6464+ end
6565+ end
6666+6767+ context 'when HTTP response has a trailing newline' do
6868+ it 'should accept it' do
6969+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
7070+ .to_return(body: sample_did + "\n")
7171+7272+ result = subject.resolve_handle(handle)
7373+7474+ result.should_not be_nil
7575+ result.should be_a(DID)
7676+ result.to_s.should == sample_did
7777+ end
7878+ end
7979+ end
8080+8181+ context 'when handle has a leading @' do
8282+ let(:handle) { '@pfrazee.com' }
8383+8484+ before do
8585+ Resolv::DNS.stubs(:open).returns([])
8686+ end
8787+8888+ it 'should also return a matching DID' do
8989+ stub_request(:get, "https://pfrazee.com/.well-known/atproto-did")
9090+ .to_return(body: sample_did)
9191+9292+ result = subject.resolve_handle(handle)
9393+9494+ result.should_not be_nil
9595+ result.should be_a(DID)
9696+ result.to_s.should == sample_did
9797+ result.resolved_by.should == :http
9898+ end
9999+ end
100100+101101+ context 'when handle has a reserved TLD' do
102102+ let(:handle) { 'example.test' }
103103+104104+ it 'should return nil' do
105105+ subject.resolve_handle(handle).should be_nil
106106+ end
107107+ end
108108+109109+ context 'when a DID string is passed' do
110110+ let(:handle) { BSKY_APP_DID }
111111+112112+ it 'should return that DID' do
113113+ result = subject.resolve_handle(handle)
114114+115115+ result.should be_a(DID)
116116+ result.to_s.should == BSKY_APP_DID
117117+ end
118118+ end
119119+120120+ context 'when a DID object is passed' do
121121+ let(:handle) { DID.new(BSKY_APP_DID) }
122122+123123+ it 'should return a new DID object with that DID' do
124124+ result = subject.resolve_handle(handle)
125125+126126+ result.should be_a(DID)
127127+ result.to_s.should == BSKY_APP_DID
128128+ result.equal?(handle).should == false
129129+ end
130130+ end
131131+ end
132132+133133+ describe '#resolve_did' do
134134+ context 'when passed a did:plc string' do
135135+ let(:did) { 'did:plc:yk4dd2qkboz2yv6tpubpc6co' }
136136+137137+ it 'should return a parsed DID document object' do
138138+ stub_request(:get, "https://plc.directory/#{did}")
139139+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
140140+141141+ result = subject.resolve_did(did)
142142+ result.should be_a(DIDKit::Document)
143143+ result.handles.should == ['dholms.xyz']
144144+ result.pds_endpoint.should == 'https://pds.dholms.xyz'
145145+ end
146146+147147+ it 'should require a valid content type' do
148148+ stub_request(:get, "https://plc.directory/#{did}")
149149+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'text/plain' })
150150+151151+ expect { subject.resolve_did(did) }.to raise_error(DIDKit::APIError)
152152+ end
153153+ end
154154+155155+ context 'when passed a did:web string' do
156156+ let(:did) { 'did:web:witchcraft.systems' }
157157+158158+ it 'should return a parsed DID document object' do
159159+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
160160+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
161161+162162+ result = subject.resolve_did(did)
163163+ result.should be_a(DIDKit::Document)
164164+ result.handles.should == ['witchcraft.systems']
165165+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
166166+ end
167167+168168+ it 'should NOT require a valid content type' do
169169+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
170170+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'text/plain' })
171171+172172+ result = subject.resolve_did(did)
173173+ result.should be_a(DIDKit::Document)
174174+ result.handles.should == ['witchcraft.systems']
175175+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
176176+ end
177177+ end
178178+ end
179179+end
+44-5
spec/spec_helper.rb
···11# frozen_string_literal: true
2233-require "didkit"
33+require 'simplecov'
44+55+SimpleCov.start do
66+ enable_coverage :branch
77+ add_filter "/spec/"
88+end
99+1010+require 'didkit'
1111+require 'json'
1212+require 'webmock/rspec'
413514RSpec.configure do |config|
615 # Enable flags like --only-failures and --next-failure
716 config.example_status_persistence_file_path = ".rspec_status"
81799- # Disable RSpec exposing methods globally on `Module` and `main`
1010- config.disable_monkey_patching!
1818+ config.expect_with :rspec do |c|
1919+ c.syntax = [:should, :expect]
2020+ end
2121+2222+ config.mock_with :mocha
2323+end
2424+2525+module SimpleCov
2626+ module Formatter
2727+ class HTMLFormatter
2828+ def format(result)
2929+ # silence the stdout summary, just save the html files
3030+ unless @inline_assets
3131+ Dir[File.join(@public_assets_dir, "*")].each do |path|
3232+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
3333+ end
3434+ end
11351212- config.expect_with :rspec do |c|
1313- c.syntax = :expect
3636+ File.open(File.join(output_path, "index.html"), "wb") do |file|
3737+ file.puts template("layout").result(binding)
3838+ end
3939+ end
4040+ end
1441 end
1542end
4343+4444+BSKY_APP_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur'
4545+4646+WebMock.enable!
4747+4848+def load_did_file(name)
4949+ File.read(File.join(__dir__, 'dids', name))
5050+end
5151+5252+def load_did_json(name)
5353+ JSON.parse(load_did_file(name))
5454+end