···000000000000000000000000000000000000000000000000000000000000000000001## [0.0.3] - 2024-03-06
23- added `Document#handles` with handle info extracted from `alsoKnownAs` field
···1+## [0.3.1] - 2025-12-19
2+3+- 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
4+- allow passing another DID object to `DID.new` and return a copy of that DID
5+- parse `seq` field in `PLCOperation` if included and expose it as a property
6+- fixed some errors on Rubies older than 3.2 due to missing `filter_map` and `URI#origin`
7+- `PLCOperation` verifies if the argument is a `Hash`
8+9+## [0.3.0] - 2025-12-15
10+11+Breaking changes:
12+13+* removed `DID#is_known_by_relay?` โ it doesn't work anymore, since relays are now non-archival and they expose almost no XRPC routes
14+* renamed a few handle-related methods:
15+ - `get_validated_handle` -> `get_verified_handle`
16+ - `pick_valid_handle` -> `first_verified_handle`
17+18+Also:
19+20+- added `DID#account_status` method, which checks `getRepoStatus` endpoint to tell if an account is active, deactivated, taken down etc.
21+- added `DID#account_active?` helper (`account_status == :active`)
22+- `DID#account_exists?` now calls `getRepoStatus` (via `account_status`, checking if it's not nil) instead of `getLatestCommit`
23+- added `DID#document` which keeps a memoized copy of the document
24+- added `pds_host` & `labeler_host` methods to `PLCOperation` and `Document`, which return the PDS/labeller address without the `https://`
25+- added `labeller_endpoint` & `labeller_host` aliases for the double-L enjoyers :]
26+- added `PLCOperation#cid`
27+- `PLCImporter` now removes duplicate operations at the edge of pages returned from the `/export` API
28+- rewritten some networking code โ all classes now use `Net::HTTP` with consistent options instead of `open-uri`
29+30+Note: `PLCImporter` will be rewritten soon to add support for updated [plc.directory](https://plc.directory) APIs, so be prepared for some breaking changes there in v. 0.4.
31+32+## [0.2.3] - 2024-07-02
33+34+- added a `DID#get_audit_log` method that fetches the PLC audit log for a DID
35+- added a way to set an error handler in `PLCImporter`
36+- reverted the change from 0.2.1 that added Ruby stdlib dependencies explicitly to the gemspec, since this causes more problems than it's worth
37+- minor bug fixes
38+39+## [0.2.2] - 2024-04-01
40+41+- added helpers for checking if a DID is known by (federated with) a relay or if the repo exists on its assigned PDS
42+43+## [0.2.1] - 2024-03-26
44+45+- tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
46+- added Ruby stdlib dependencies explicitly to the gemspec
47+48+## [0.2.0] - 2024-03-19
49+50+- added `PLCImporter` class, which lets you import operations from PLC in pages of 1000 through the "export" API
51+- implemented parsing of all services from DID doc & operations, not only `atproto_pds` (specifically labeller endpoints)
52+- allow setting the nameserver in `Resolver` initializer
53+54+## [0.1.0] - 2024-03-12
55+56+- rejecting handles from disallowed domains like `.arpa` or `.test`
57+- validating handles with the `.well-known` file having a trailing newline
58+- validating handles with `.well-known` address returning a redirect
59+- added `#pick_valid_handle` helper
60+- allow overriding the nameserver for `Resolv::DNS`
61+- other bug fixes
62+63+## [0.0.4] - 2024-03-07
64+65+- extracted resolving code from `DID` to a new `Resolver` class (`DID` has helper methods to call the resolver)
66+- added `Resolver#get_validated_handle` method to validate handles from the `Document` (+ helpers in `DID` in `Document`)
67+- added timeout to `#resolve_handle_by_well_known`
68+69## [0.0.3] - 2024-03-06
7071- added `Document#handles` with handle info extracted from `alsoKnownAs` field
+10-2
Gemfile
···5# Specify your gem's dependencies in didkit.gemspec
6gemspec
78-gem "rake", "~> 13.0"
9-gem "rspec", "~> 3.0"
00000000
···1The zlib License
23-Copyright (c) 2023 Jakub Suder
45This software is provided 'as-is', without any express or implied
6warranty. In no event will the authors be held liable for any damages
···1The zlib License
23+Copyright (c) 2026 Jakub Suder
45This software is provided 'as-is', without any express or implied
6warranty. In no event will the authors be held liable for any damages
+100-7
README.md
···1-# DidKit
0023-A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol
0456## What does it do
78-**TODO** - not much yet :)
00000000000000000000000000000000000000000000000000000000000000000000000000910-See the [did.rb](https://github.com/mackuba/didkit/blob/master/lib/didkit/did.rb) file for now.
110001213-## Installation
1415- gem install didkit
01600000000001718## Credits
1920-Copyright ยฉ 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
2122The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
00
···1+# DIDKit ๐ชช
2+3+A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol.
45+> [!NOTE]
6+> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
789## What does it do
1011+Accounts on Bluesky use identifiers like [did:plc:oio4hkxaop4ao4wz2pp3f4cr](https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr) as unique IDs, and they also have assigned human-readable handles like [@mackuba.eu](https://bsky.app/profile/mackuba.eu), which are verified either through a DNS TXT entry or a `/.well-known/atproto-did` file. This library allows you to look up any account's assigned handle using a DID string or vice versa, load the account's DID JSON document that specifies the handles and the PDS server hosting user's repo, and check if the assigned handle verifies correctly.
12+13+14+## Installation
15+16+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/)).
17+18+To install the gem, run in the command line:
19+20+ [sudo] gem install didkit
21+22+Or add this to your app's `Gemfile`:
23+24+ gem 'didkit', '~> 0.3'
25+26+27+## Usage
28+29+The simplest way to use the gem is through the `DIDKit::DID` class, aliased as just `DID`:
30+31+```rb
32+did = DID.resolve_handle('jay.bsky.team')
33+ # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo",
34+ # @resolved_by=:dns, @type=:plc>
35+```
36+37+This returns a `DID` object, which tells you:
38+39+- the DID as a string (`#to_s` or `#did`)
40+- the DID type (`#type`, `:plc` or `:web`)
41+- if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
42+43+To go in the other direction โ to find an assigned and verified handle given a DID โ create a `DID` from a DID string and call `get_verified_handle`:
44+45+```rb
46+DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle
47+ # => "atproto.com"
48+```
49+50+You can also load the DID JSON document using `#document`, which returns a `DIDKit::Document` (`DID` caches the document, so don't worry about calling this method multiple times):
51+52+```rb
53+did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
54+55+did.document.handles
56+ # => ["pfrazee.com"]
57+58+did.document.pds_host
59+ # => "morel.us-east.host.bsky.network"
60+```
61+62+63+### Checking account status
64+65+`DIDKit::DID` also includes a few methods for checking the status of a given account (repo), which call the `com.atproto.sync.getRepoStatus` endpoint on the account's assigned PDS:
66+67+```rb
68+did = DID.new('did:plc:ch7azdejgddtlijyzurfdihn')
69+did.account_status
70+ # => :takendown
71+did.account_active?
72+ # => false
73+did.account_exists?
74+ # => true
75+76+did = DID.new('did:plc:44ybard66vv44zksje25o7dz')
77+did.account_status
78+ # => :active
79+did.account_active?
80+ # => true
81+```
82+83+### Configuration
84+85+You can customize some things about the DID/handle lookups by using the `DIDKit::Resolver` class, which the methods in `DID` use behind the scenes.
8687+Currently available options include:
8889+- `:nameserver` - override the nameserver used for DNS lookups, e.g. to use Google's or CloudFlare's DNS
90+- `:timeout` - change the connection/response timeout for HTTP requests (default: 15 s)
91+- `:max_redirects` - change allowed maximum number of redirects (default: 5)
9293+Example:
9495+```rb
96+resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8', timeout: 30)
9798+did = resolver.resolve_handle('nytimes.com')
99+ # => #<DIDKit::DID:0x0... @did="did:plc:eclio37ymobqex2ncko63h4r",
100+ # @resolved_by=:dns, @type=:plc>
101+102+resolver.resolve_did(did)
103+ # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}>
104+105+resolver.get_verified_handle(did)
106+ # => 'nytimes.com'
107+```
108109## Credits
110111+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
112113The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
114+115+Bug reports and pull requests are welcome ๐
···1require 'json'
2-require 'net/http'
3-require 'open-uri'
4-require 'resolv'
56-require_relative 'document'
7require_relative 'errors'
0089module DIDKit
10- class DID
11- def self.resolve_handle(handle)
12- domain = handle.gsub(/^@/, '')
1314- if dns_did = resolve_handle_by_dns(domain)
15- DID.new(dns_did, :dns)
16- elsif http_did = resolve_handle_by_well_known(domain)
17- DID.new(http_did, :http)
18- else
19- nil
20- end
21- end
2223- def self.resolve_handle_by_dns(domain)
24- dns_records = Resolv::DNS.open { |d| d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) }
2526- if record = dns_records.first
27- if string = record.strings.first
28- if string =~ /^did\=(did\:\w+\:.*)$/
29- return $1
30- end
31- end
32- end
3334- nil
0000000000035 end
3637- def self.resolve_handle_by_well_known(domain)
38- url = URI("https://#{domain}/.well-known/atproto-did")
39- response = Net::HTTP.get_response(url)
4041- if response.is_a?(Net::HTTPSuccess)
42- if text = response.body
43- if text.lines.length == 1 && text.start_with?('did:')
44- return text
45- end
46- end
47- end
4849- nil
50- rescue StandardError => e
51- nil
52- end
5354- attr_reader :type, :did, :resolved_by
000005556 def initialize(did, resolved_by = nil)
57- if did =~ /^did\:(\w+)\:/
000058 @did = did
59- @type = $1.to_sym
60 else
61 raise DIDError.new("Invalid DID format")
62 end
···68 @resolved_by = resolved_by
69 end
7071- alias to_s did
00000000007273 def get_document
74- type == :plc ? resolve_did_plc : resolve_did_web
75 end
760000000000000000000000000000077 def web_domain
78 did.gsub(/^did\:web\:/, '') if type == :web
79 end
8081- def resolve_did_plc
82- url = "https://plc.directory/#{did}"
83- json = JSON.parse(URI.open(url).read)
84- Document.new(self, json)
0000000000000000000000000000000085 end
8687- def resolve_did_web
88- url = "https://#{web_domain}/.well-known/did.json"
89- json = JSON.parse(URI.open(url).read)
90- Document.new(self, json)
0000000000000000000000000000091 end
92 end
93end
···1require 'json'
2+require 'uri'
00304require_relative 'errors'
5+require_relative 'requests'
6+require_relative 'resolver'
78module DIDKit
000910+ #
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+ #
1819+ class DID
20+ GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
2122+ include Requests
0000002324+ # 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+34+ def self.resolve_handle(handle)
35+ Resolver.new.resolve_handle(handle)
36 end
3738+ # @return [Symbol] DID type (`:plc` or `:web`)
39+ attr_reader :type
04041+ # @return [String] DID identifier string
42+ attr_reader :did
000004344+ # @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
4849+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
5556 def initialize(did, resolved_by = nil)
57+ if did.is_a?(DID)
58+ did = did.to_s
59+ end
60+61+ if did =~ GENERIC_REGEXP
62 @did = did
63+ @type = did.split(':')[1].to_sym
64 else
65 raise DIDError.new("Invalid DID format")
66 end
···72 @resolved_by = resolved_by
73 end
7475+ # 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
79+80+ def document
81+ @document ||= get_document
82+ end
83+84+ # Looks up the DID document with the DID's identity details from an appropriate source.
85+ # @return [Document] resolved DID document
8687 def get_document
88+ Resolver.new.resolve_did(self)
89 end
9091+ # 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+99+ def get_verified_handle
100+ Resolver.new.get_verified_handle(document)
101+ end
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+108+ def get_audit_log
109+ if @type == :plc
110+ PLCImporter.new.fetch_audit_log(self)
111+ else
112+ raise DIDError.new("Audit log not supported for did:#{@type}")
113+ end
114+ end
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+120 def web_domain
121 did.gsub(/^did\:web\:/, '') if type == :web
122 end
123124+ # 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+133+ def account_status(request_options = {})
134+ doc = self.document
135+ return nil if doc.pds_endpoint.nil?
136+137+ pds_host = uri_origin(doc.pds_endpoint)
138+ url = URI("#{pds_host}/xrpc/com.atproto.sync.getRepoStatus")
139+ url.query = URI.encode_www_form(:did => @did)
140+141+ response = get_response(url, request_options)
142+ status = response.code.to_i
143+ is_json = (response['Content-Type'] =~ /^application\/json(;.*)?$/)
144+145+ if status == 200 && is_json
146+ json = JSON.parse(response.body)
147+148+ if json['active'] == true
149+ :active
150+ elsif json['active'] == false && json['status'].is_a?(String) && json['status'].length <= 100
151+ json['status'].to_sym
152+ else
153+ raise APIError.new(response)
154+ end
155+ elsif status == 400 && is_json && JSON.parse(response.body)['error'] == 'RepoNotFound'
156+ nil
157+ else
158+ raise APIError.new(response)
159+ end
160 end
161162+ # 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+168+ def account_active?
169+ account_status == :active
170+ end
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+178+ def account_exists?
179+ account_status != nil
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
186+187+ def ==(other)
188+ if other.is_a?(String)
189+ self.did == other
190+ elsif other.is_a?(DID)
191+ self.did == other.did
192+ else
193+ false
194+ end
195 end
196 end
197end
···1+require_relative 'at_handles'
2+require_relative 'errors'
3+require_relative 'resolver'
4+require_relative 'service_record'
5+require_relative 'services'
6+7module 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+15 class Document
16+ include AtHandles
17+ include Services
1819+ # @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.
4142 def initialize(did, json)
43 raise FormatError, "Missing id field" if json['id'].nil?
44 raise FormatError, "Invalid id field" unless json['id'].is_a?(String)
45+ raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s
4647 @did = did
48 @json = json
4950+ @services = parse_services(json['service'] || [])
51+ @handles = parse_also_known_as(json['alsoKnownAs'] || [])
52+ end
5354+ # 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
6162+ def get_verified_handle
63+ Resolver.new.get_verified_handle(self)
64+ end
6566+67+ private
006869+ def parse_services(service_data)
70+ raise FormatError, "Invalid service data" unless service_data.is_a?(Array) && service_data.all? { |x| x.is_a?(Hash) }
71+72+ services = []
73+74+ service_data.each do |x|
75+ id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
76+77+ if id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
78+ services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
79+ end
80 end
81+82+ services
83 end
84 end
85end
+35
lib/didkit/errors.rb
···1module DIDKit
000000000000000000000000000002 class DIDError < StandardError
0000003 end
4end
···1module DIDKit
2+3+ #
4+ # Raised when an HTTP request returns a response with an error status.
5+ #
6+ class APIError < StandardError
7+8+ # @return [Net::HTTPResponse] the returned HTTP response
9+ attr_reader :response
10+11+ # @param response [Net::HTTPResponse] the returned HTTP response
12+ def initialize(response)
13+ @response = response
14+ super("APIError: #{response}")
15+ end
16+17+ # @return [Integer] HTTP status code
18+ def status
19+ response.code.to_i
20+ end
21+22+ # @return [String] HTTP response body
23+ def body
24+ response.body
25+ end
26+ end
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+ #
37+ class FormatError < StandardError
38 end
39end
···1+require 'time'
2+3+require_relative 'at_handles'
4+require_relative 'errors'
5+require_relative 'service_record'
6+require_relative 'services'
7+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+15+ class PLCOperation
16+ include AtHandles
17+ include Services
18+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
56+57+ def initialize(json)
58+ @json = json
59+ raise FormatError, "Expected argument to be a Hash, got a #{json.class}" unless @json.is_a?(Hash)
60+61+ @seq = json['seq']
62+ @did = json['did']
63+ raise FormatError, "Missing DID: #{json}" if @did.nil?
64+ raise FormatError, "Invalid DID: #{@did.inspect}" unless @did.is_a?(String) && @did.start_with?('did:')
65+66+ @cid = json['cid']
67+ raise FormatError, "Missing CID: #{json}" if @cid.nil?
68+ raise FormatError, "Invalid CID: #{@cid}" unless @cid.is_a?(String)
69+70+ timestamp = json['createdAt']
71+ raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
72+ raise FormatError, "Invalid createdAt: #{timestamp.inspect}" unless timestamp.is_a?(String)
73+74+ @created_at = Time.parse(timestamp)
75+76+ operation = json['operation']
77+ raise FormatError, "Missing operation key: #{json}" if operation.nil?
78+ raise FormatError, "Invalid operation data: #{operation.inspect}" unless operation.is_a?(Hash)
79+80+ type = operation['type']
81+ raise FormatError, "Missing operation type: #{json}" if type.nil?
82+83+ @type = type.to_sym
84+ return unless @type == :plc_operation
85+86+ services = operation['services']
87+ raise FormatError, "Missing services key: #{json}" if services.nil?
88+ raise FormatError, "Invalid services data: #{services}" unless services.is_a?(Hash)
89+90+ @services = services.map { |k, x|
91+ type, endpoint = x.values_at('type', 'endpoint')
92+93+ raise FormatError, "Missing service type" unless type
94+ raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
95+ raise FormatError, "Missing service endpoint" unless endpoint
96+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
97+98+ ServiceRecord.new(k, type, endpoint)
99+ }
100+101+ @handles = parse_also_known_as(operation['alsoKnownAs'])
102+ end
103+ end
104+end
···1+require 'net/http'
2+require 'resolv'
3+4+require_relative 'did'
5+require_relative 'document'
6+require_relative 'requests'
7+8+module DIDKit
9+10+ #
11+ # A class which manages resolving of handles to DIDs and DIDs to DID documents.
12+ #
13+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.
17+ RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
18+19+ include Requests
20+21+ # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups
22+ attr_accessor :nameserver
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+29+ def initialize(options = {})
30+ @nameserver = options[:nameserver]
31+ @request_options = options.slice(:timeout, :max_redirects)
32+ end
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+44+ def resolve_handle(handle)
45+ if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP
46+ return DID.new(handle)
47+ end
48+49+ domain = handle.gsub(/^@/, '')
50+51+ return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
52+53+ if dns_did = resolve_handle_by_dns(domain)
54+ DID.new(dns_did, :dns)
55+ elsif http_did = resolve_handle_by_well_known(domain)
56+ DID.new(http_did, :http)
57+ else
58+ nil
59+ end
60+ end
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+70+ def resolve_handle_by_dns(domain)
71+ dns_records = Resolv::DNS.open(resolv_options) do |d|
72+ d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
73+ end
74+75+ if record = dns_records.first
76+ if string = record.strings.first
77+ return parse_did_from_dns(string)
78+ end
79+ end
80+81+ nil
82+ end
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+92+ def resolve_handle_by_well_known(domain)
93+ url = "https://#{domain}/.well-known/atproto-did"
94+ response = get_response(url, @request_options)
95+96+ if response.is_a?(Net::HTTPSuccess) && (text = response.body)
97+ return parse_did_from_well_known(text)
98+ end
99+100+ nil
101+ rescue StandardError => e
102+ nil
103+ end
104+105+ # Resolve a DID to a DID document.
106+ #
107+ # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
108+ # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs.
109+ #
110+ # @param did [String, DID] DID string or object
111+ # @return [Document] resolved DID document
112+ # @raise [APIError] if an incorrect response is returned
113+114+ def resolve_did(did)
115+ did = DID.new(did) if did.is_a?(String)
116+117+ did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
118+ end
119+120+ # Returns the first verified handle assigned to the given DID.
121+ #
122+ # Looks up the domain handles assigned to the DID in the DID document, checks if they are
123+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
124+ # the first handle that validates correctly, or nil if none matches.
125+ #
126+ # @param subject [String, DID, Document] a DID or its DID document
127+ # @return [String, nil] verified handle domain, if found
128+129+ def get_verified_handle(subject)
130+ document = subject.is_a?(Document) ? subject : resolve_did(subject)
131+132+ first_verified_handle(document.did, document.handles)
133+ end
134+135+ # Returns the first handle from the list that resolves back to the given DID.
136+ #
137+ # @param did [DID, String] DID to verify the handles against
138+ # @param handles [Array<String>] handles to check
139+ # @return [String, nil] a verified handle, if found
140+141+ def first_verified_handle(did, handles)
142+ handles.detect { |h| resolve_handle(h) == did.to_s }
143+ end
144+145+146+ private
147+148+ def resolv_options
149+ options = Resolv::DNS::Config.default_config_hash.dup
150+ options[:nameserver] = nameserver if nameserver
151+ options
152+ end
153+154+ def parse_did_from_dns(txt)
155+ txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil
156+ end
157+158+ def parse_did_from_well_known(text)
159+ text = text.strip
160+ text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil
161+ end
162+163+ def resolve_did_plc(did)
164+ json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/)
165+ Document.new(did, json)
166+ end
167+168+ def resolve_did_web(did)
169+ json = get_json("https://#{did.web_domain}/.well-known/did.json")
170+ Document.new(did, json)
171+ end
172+ end
173+end
+41
lib/didkit/service_record.rb
···00000000000000000000000000000000000000000
···1+require 'uri'
2+require_relative 'errors'
3+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+9+ class ServiceRecord
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
28+29+ def initialize(key, type, endpoint)
30+ begin
31+ uri = URI(endpoint)
32+ rescue URI::Error
33+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}"
34+ end
35+36+ @key = key
37+ @type = type
38+ @endpoint = endpoint
39+ end
40+ end
41+end
···1+require 'uri'
2+3+module DIDKit
4+5+ #
6+ # @api private
7+ #
8+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+18+ def get_service(key, type)
19+ @services&.detect { |s| s.key == key && s.type == type }
20+ end
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+31+ def pds_endpoint
32+ @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
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
42+43+ def labeler_endpoint
44+ @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
45+ end
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+52+ def pds_host
53+ pds_endpoint&.then { |x| URI(x).host }
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
60+61+ def labeler_host
62+ labeler_endpoint&.then { |x| URI(x).host }
63+ end
64+65+ alias labeller_endpoint labeler_endpoint
66+ alias labeller_host labeler_host
67+ end
68+end
+1-1
lib/didkit/version.rb
···1# frozen_string_literal: true
23module DIDKit
4- VERSION = "0.0.3"
5end
···1# frozen_string_literal: true
23module DIDKit
4+ VERSION = "0.3.1"
5end
···1+describe DIDKit::DID do
2+ subject { described_class }
3+4+ let(:plc_did) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
5+ let(:web_did) { 'did:web:taylorswift.com' }
6+7+ describe '#initialize' do
8+ context 'with a valid did:plc' do
9+ it 'should return an initialized DID object' do
10+ did = subject.new(plc_did)
11+12+ did.should be_a(DIDKit::DID)
13+ did.type.should == :plc
14+ did.did.should be_a(String)
15+ did.did.should == plc_did
16+ did.resolved_by.should be_nil
17+ end
18+ end
19+20+ context 'with a valid did:web' do
21+ it 'should return an initialized DID object' do
22+ did = subject.new(web_did)
23+24+ did.should be_a(DIDKit::DID)
25+ did.type.should == :web
26+ did.did.should be_a(String)
27+ did.did.should == web_did
28+ did.resolved_by.should be_nil
29+ end
30+ end
31+32+ context 'with another DID object' do
33+ it 'should create a copy of the DID' do
34+ other = subject.new(plc_did)
35+ did = subject.new(other)
36+37+ did.did.should == plc_did
38+ did.type.should == :plc
39+ did.equal?(other).should == false
40+ end
41+ end
42+43+ context 'with a string that is not a DID' do
44+ it 'should raise an error' do
45+ expect {
46+ subject.new('not-a-did')
47+ }.to raise_error(DIDKit::DIDError)
48+ end
49+ end
50+51+ context 'when an unrecognized did: type' do
52+ it 'should raise an error' do
53+ expect {
54+ subject.new('did:example:123')
55+ }.to raise_error(DIDKit::DIDError)
56+ end
57+ end
58+ end
59+60+ describe '#web_domain' do
61+ context 'for a did:web' do
62+ it 'should return the domain part' do
63+ did = subject.new('did:web:site.example.com')
64+65+ did.web_domain.should == 'site.example.com'
66+ end
67+ end
68+69+ context 'for a did:plc' do
70+ it 'should return nil' do
71+ did = subject.new('did:plc:yk4dd2qkboz2yv6tpubpc6co')
72+73+ did.web_domain.should be_nil
74+ end
75+ end
76+ end
77+78+ describe '#==' do
79+ let(:did_string) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
80+ let(:other_string) { 'did:plc:oio4hkxaop4ao4wz2pp3f4cr' }
81+82+ let(:did) { subject.new(did_string) }
83+ let(:other) { subject.new(other_string) }
84+85+ context 'given a DID string' do
86+ it 'should compare its string value to the other DID' do
87+ did.should == did_string
88+ did.should_not == other_string
89+ end
90+ end
91+92+ context 'given another DID object' do
93+ it "should compare its string value to the other DID's string value" do
94+ copy = subject.new(did_string)
95+96+ did.should == copy
97+ did.should_not == other
98+ end
99+ end
100+101+ context 'given something that is not a DID' do
102+ it 'should return false' do
103+ did.should_not == :didplc
104+ did.should_not == [did_string]
105+ end
106+ end
107+ end
108+109+ describe '#to_s' do
110+ it "should return the DID's string value" do
111+ did = subject.new(plc_did)
112+113+ did.to_s.should be_a(String)
114+ did.to_s.should == plc_did
115+ end
116+ end
117+118+ describe 'account status' do
119+ let(:document) { stub(:pds_endpoint => 'https://pds.ruby.space') }
120+ let(:did) { subject.new(plc_did) }
121+122+ before do
123+ did.stubs(:document).returns(document)
124+125+ stub_request(:get, 'https://pds.ruby.space/xrpc/com.atproto.sync.getRepoStatus')
126+ .with(query: { did: plc_did })
127+ .to_return(http_response) if defined?(http_response)
128+ end
129+130+ context 'when repo is active' do
131+ let(:http_response) {
132+ { body: { active: true }.to_json, headers: { 'Content-Type' => 'application/json' }}
133+ }
134+135+ it 'should report active account state' do
136+ did.account_status.should == :active
137+ did.account_active?.should == true
138+ did.account_exists?.should == true
139+ end
140+ end
141+142+ context 'when repo is inactive' do
143+ let(:http_response) {
144+ { body: { active: false, status: 'takendown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
145+ }
146+147+ it 'should report an inactive existing account' do
148+ did.account_status.should == :takendown
149+ did.account_active?.should == false
150+ did.account_exists?.should == true
151+ end
152+ end
153+154+ context 'when repo is not found' do
155+ let(:http_response) {
156+ { status: 400, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
157+ }
158+159+ it 'should return nil status and report the account as missing' do
160+ did.account_status.should be_nil
161+ did.account_active?.should == false
162+ did.account_exists?.should == false
163+ end
164+ end
165+166+ context 'when the document has no pds endpoint' do
167+ before do
168+ did.stubs(:document).returns(stub(:pds_endpoint => nil))
169+ end
170+171+ it 'should return nil status and report the account as missing' do
172+ did.account_status.should be_nil
173+ did.account_active?.should == false
174+ did.account_exists?.should == false
175+ end
176+ end
177+178+ context 'when active field is not set' do
179+ let(:http_response) {
180+ { body: { active: nil, status: 'unknown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
181+ }
182+183+ it 'should raise APIError' do
184+ expect { did.account_status }.to raise_error(DIDKit::APIError)
185+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
186+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
187+ end
188+ end
189+190+ context 'when active is false but status is not set' do
191+ let(:http_response) {
192+ { body: { active: false, status: nil }.to_json, headers: { 'Content-Type' => 'application/json' }}
193+ }
194+195+ it 'should raise APIError' do
196+ expect { did.account_status }.to raise_error(DIDKit::APIError)
197+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
198+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
199+ end
200+ end
201+202+ context 'when an error different than RepoNotFound is returned' do
203+ let(:http_response) {
204+ { status: 400, body: { error: 'UserIsJerry' }.to_json, headers: { 'Content-Type' => 'application/json' }}
205+ }
206+207+ it 'should raise APIError' do
208+ expect { did.account_status }.to raise_error(DIDKit::APIError)
209+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
210+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
211+ end
212+ end
213+214+ context 'when the response is not application/json' do
215+ let(:http_response) {
216+ { status: 400, body: 'error', headers: { 'Content-Type' => 'text/html' }}
217+ }
218+219+ it 'should raise APIError' do
220+ expect { did.account_status }.to raise_error(DIDKit::APIError)
221+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
222+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
223+ end
224+ end
225+226+ context 'when the response is not 200 or 400' do
227+ let(:http_response) {
228+ { status: 500, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
229+ }
230+231+ it 'should raise APIError' do
232+ expect { did.account_status }.to raise_error(DIDKit::APIError)
233+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
234+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
235+ end
236+ end
237+ end
238+end
+2-2
spec/didkit_spec.rb
···1# frozen_string_literal: true
23-RSpec.describe Didkit do
4 it "has a version number" do
5- expect(Didkit::VERSION).not_to be nil
6 end
7end
···1# frozen_string_literal: true
23+RSpec.describe DIDKit do
4 it "has a version number" do
5+ expect(DIDKit::VERSION).not_to be nil
6 end
7end
···1+require 'time'
2+3+describe DIDKit::PLCOperation do
4+ subject { described_class }
5+6+ let(:base_json) { load_did_json('bnewbold_log.json').last }
7+8+ describe '#initialize' do
9+ context 'with a valid plc operation' do
10+ let(:json) { base_json }
11+12+ it 'should return a PLCOperation with parsed data' do
13+ op = subject.new(json)
14+15+ op.json.should == json
16+ op.type.should == :plc_operation
17+ op.did.should == 'did:plc:44ybard66vv44zksje25o7dz'
18+ op.cid.should == 'bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m'
19+ op.created_at.should be_a(Time)
20+ op.created_at.should == Time.parse("2025-06-06T00:34:40.824Z")
21+ op.handles.should == ['bnewbold.net']
22+ op.services.map(&:key).should == ['atproto_pds']
23+ end
24+ end
25+26+ context 'when argument is not a hash' do
27+ let(:json) { [base_json] }
28+29+ it 'should raise a format error' do
30+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
31+ end
32+ end
33+34+ context 'when did is missing' do
35+ let(:json) { base_json.tap { |h| h.delete('did') }}
36+37+ it 'should raise a format error' do
38+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
39+ end
40+ end
41+42+ context 'when did is not a string' do
43+ let(:json) { base_json.merge('did' => 123) }
44+45+ it 'should raise a format error' do
46+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
47+ end
48+ end
49+50+ context "when did doesn't start with did:" do
51+ let(:json) { base_json.merge('did' => 'foobar') }
52+53+ it 'should raise a format error' do
54+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
55+ end
56+ end
57+58+ context 'when cid is missing' do
59+ let(:json) { base_json.tap { |h| h.delete('cid') }}
60+61+ it 'should raise a format error' do
62+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
63+ end
64+ end
65+66+ context 'when cid is not a string' do
67+ let(:json) { base_json.merge('cid' => 700) }
68+69+ it 'should raise a format error' do
70+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
71+ end
72+ end
73+74+ context 'when createdAt is missing' do
75+ let(:json) { base_json.tap { |h| h.delete('createdAt') }}
76+77+ it 'should raise a format error' do
78+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
79+ end
80+ end
81+82+ context 'when createdAt is invalid' do
83+ let(:json) { base_json.merge('createdAt' => 123) }
84+85+ it 'should raise a format error' do
86+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
87+ end
88+ end
89+90+ context 'when operation block is missing' do
91+ let(:json) { base_json.tap { |h| h.delete('operation') }}
92+93+ it 'should raise a format error' do
94+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
95+ end
96+ end
97+98+ context 'when operation block is not a hash' do
99+ let(:json) { base_json.merge('operation' => 'invalid') }
100+101+ it 'should raise a format error' do
102+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
103+ end
104+ end
105+106+ context 'when operation type is missing' do
107+ let(:json) { base_json.tap { |h| h['operation'].delete('type') }}
108+109+ it 'should raise a format error' do
110+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
111+ end
112+ end
113+114+ context 'when operation type is not plc_operation' do
115+ let(:json) { base_json.tap { |h| h['operation']['type'] = 'other' }}
116+117+ it 'should not raise an error' do
118+ expect { subject.new(json) }.not_to raise_error
119+ end
120+121+ it 'should return the operation type' do
122+ op = subject.new(json)
123+ op.type.should == :other
124+ end
125+126+ it 'should not try to parse services' do
127+ json['services'] = nil
128+129+ expect { subject.new(json) }.not_to raise_error
130+ end
131+132+ it 'should return nil from services' do
133+ op = subject.new(json)
134+ op.services.should be_nil
135+ end
136+137+ it 'should not try to parse handles' do
138+ json['alsoKnownAs'] = nil
139+140+ expect { subject.new(json) }.not_to raise_error
141+ end
142+143+ it 'should return nil from handles' do
144+ op = subject.new(json)
145+ op.handles.should be_nil
146+ end
147+ end
148+149+ context 'when alsoKnownAs is not an array' do
150+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = 'at://dholms.xyz' }}
151+152+ it 'should raise an AtHandles format error' do
153+ expect {
154+ subject.new(json)
155+ }.to raise_error(DIDKit::FormatError)
156+ end
157+ end
158+159+ context 'when alsoKnownAs elements are not strings' do
160+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = [666] }}
161+162+ it 'should raise an AtHandles format error' do
163+ expect {
164+ subject.new(json)
165+ }.to raise_error(DIDKit::FormatError)
166+ end
167+ end
168+169+ context 'when alsoKnownAs contains multiple handles' do
170+ let(:json) {
171+ base_json.tap { |h|
172+ h['operation']['alsoKnownAs'] = [
173+ 'at://dholms.xyz',
174+ 'https://example.com',
175+ 'at://other.handle'
176+ ]
177+ }
178+ }
179+180+ it 'should pick those starting with at:// and remove the prefixes' do
181+ op = subject.new(json)
182+ op.handles.should == ['dholms.xyz', 'other.handle']
183+ end
184+ end
185+186+ context 'when services are missing' do
187+ let(:json) { base_json.tap { |h| h['operation'].delete('services') }}
188+189+ it 'should raise a format error' do
190+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
191+ end
192+ end
193+194+ context 'when services entry is not a hash' do
195+ let(:json) {
196+ base_json.tap { |h|
197+ h['operation']['services'] = [
198+ {
199+ "id": "#atproto_pds",
200+ "type": "AtprotoPersonalDataServer",
201+ "serviceEndpoint": "https://pds.dholms.xyz"
202+ }
203+ ]
204+ }
205+ }
206+207+ it 'should raise a format error' do
208+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
209+ end
210+ end
211+212+ context 'when a service entry is missing fields' do
213+ let(:json) {
214+ base_json.tap { |h|
215+ h['operation']['services'] = {
216+ "atproto_pds" => {
217+ "endpoint" => "https://pds.dholms.xyz"
218+ },
219+ "atproto_labeler" => {
220+ "type" => "AtprotoLabeler",
221+ "endpoint" => "https://labeler.example.com"
222+ }
223+ }
224+ }
225+ }
226+227+ it 'should raise a format error' do
228+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
229+ end
230+ end
231+232+ context 'when services are valid' do
233+ let(:json) {
234+ base_json.tap { |h|
235+ h['operation']['services'] = {
236+ "atproto_pds" => {
237+ "type" => "AtprotoPersonalDataServer",
238+ "endpoint" => "https://pds.dholms.xyz"
239+ },
240+ "atproto_labeler" => {
241+ "type" => "AtprotoLabeler",
242+ "endpoint" => "https://labeler.example.com"
243+ },
244+ "custom_service" => {
245+ "type" => "OtherService",
246+ "endpoint" => "https://custom.example.com"
247+ }
248+ }
249+ }
250+ }
251+252+ it 'should parse services into ServiceRecords' do
253+ op = subject.new(json)
254+255+ op.services.length.should == 3
256+ op.services.each { |s| s.should be_a(DIDKit::ServiceRecord) }
257+258+ pds, labeller, custom = op.services
259+260+ pds.type.should == 'AtprotoPersonalDataServer'
261+ pds.endpoint.should == 'https://pds.dholms.xyz'
262+263+ labeller.type.should == 'AtprotoLabeler'
264+ labeller.endpoint.should == 'https://labeler.example.com'
265+266+ custom.type.should == 'OtherService'
267+ custom.endpoint.should == 'https://custom.example.com'
268+ end
269+270+ it 'should allow fetching services by key + type' do
271+ op = subject.new(json)
272+273+ custom = op.get_service('custom_service', 'OtherService')
274+ custom.should be_a(DIDKit::ServiceRecord)
275+ custom.endpoint.should == 'https://custom.example.com'
276+ end
277+278+ describe '#pds_endpoint' do
279+ it 'should return the endpoint of #atproto_pds' do
280+ op = subject.new(json)
281+ op.pds_endpoint.should == 'https://pds.dholms.xyz'
282+ end
283+ end
284+285+ describe '#pds_host' do
286+ it 'should return the host part of #atproto_pds endpoint' do
287+ op = subject.new(json)
288+ op.pds_host.should == 'pds.dholms.xyz'
289+ end
290+ end
291+292+ describe '#labeler_endpoint' do
293+ it 'should return the endpoint of #atproto_labeler' do
294+ op = subject.new(json)
295+ op.labeler_endpoint.should == 'https://labeler.example.com'
296+ end
297+ end
298+299+ describe '#labeler_host' do
300+ it 'should return the host part of #atproto_labeler endpoint' do
301+ op = subject.new(json)
302+ op.labeler_host.should == 'labeler.example.com'
303+ end
304+ end
305+306+ it 'should expose the "labeller" aliases for endpoint and host' do
307+ op = subject.new(json)
308+309+ op.labeller_endpoint.should == 'https://labeler.example.com'
310+ op.labeller_host.should == 'labeler.example.com'
311+ end
312+ end
313+314+ context 'when services are valid but the specific ones are missing' do
315+ let(:json) {
316+ base_json.tap { |h|
317+ h['operation']['services'] = {
318+ "custom_service" => {
319+ "type" => "CustomService",
320+ "endpoint" => "https://custom.example.com"
321+ }
322+ }
323+ }
324+ }
325+326+ it 'should parse service records' do
327+ op = subject.new(json)
328+ op.services.length.should == 1
329+ end
330+331+ describe '#get_service' do
332+ it 'should return nil' do
333+ op = subject.new(json)
334+ other = op.get_service('other_service', 'OtherService')
335+ other.should be_nil
336+ end
337+ end
338+339+ describe '#pds_endpoint' do
340+ it 'should return nil' do
341+ op = subject.new(json)
342+ op.pds_endpoint.should be_nil
343+ op.pds_host.should be_nil
344+ end
345+ end
346+347+ describe '#labeler_endpoint' do
348+ it 'should return nil' do
349+ op = subject.new(json)
350+ op.labeler_endpoint.should be_nil
351+ op.labeller_endpoint.should be_nil
352+ op.labeler_host.should be_nil
353+ op.labeller_host.should be_nil
354+ end
355+ end
356+ end
357+ end
358+end
···1+describe DIDKit::Resolver do
2+ let(:sample_did) { 'did:plc:qhfo22pezo44fa3243z2h4ny' }
3+4+ describe '#resolve_handle' do
5+ context 'when handle resolves via HTTP' do
6+ before do
7+ Resolv::DNS.stubs(:open).returns([])
8+ end
9+10+ let(:handle) { 'barackobama.bsky.social' }
11+12+ it 'should return a matching DID' do
13+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
14+ .to_return(body: sample_did)
15+16+ result = subject.resolve_handle(handle)
17+18+ result.should_not be_nil
19+ result.should be_a(DID)
20+ result.to_s.should == sample_did
21+ result.resolved_by.should == :http
22+ end
23+24+ it 'should check DNS first' do
25+ Resolv::DNS.expects(:open).returns([])
26+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
27+ .to_return(body: sample_did)
28+29+ result = subject.resolve_handle(handle)
30+ end
31+32+ context 'when HTTP returns invalid text' do
33+ it 'should return nil' do
34+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
35+ .to_return(body: "Welcome to nginx!")
36+37+ result = subject.resolve_handle(handle)
38+ result.should be_nil
39+ end
40+ end
41+42+ context 'when HTTP returns bad response' do
43+ it 'should return nil' do
44+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
45+ .to_return(status: 400, body: sample_did)
46+47+ result = subject.resolve_handle(handle)
48+ result.should be_nil
49+ end
50+ end
51+52+ context 'when HTTP throws an exception' do
53+ it 'should catch it and return nil' do
54+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
55+ .to_raise(Errno::ETIMEDOUT)
56+57+ result = 0
58+59+ expect {
60+ result = subject.resolve_handle(handle)
61+ }.to_not raise_error
62+63+ result.should be_nil
64+ end
65+ end
66+67+ context 'when HTTP response has a trailing newline' do
68+ it 'should accept it' do
69+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
70+ .to_return(body: sample_did + "\n")
71+72+ result = subject.resolve_handle(handle)
73+74+ result.should_not be_nil
75+ result.should be_a(DID)
76+ result.to_s.should == sample_did
77+ end
78+ end
79+ end
80+81+ context 'when handle has a leading @' do
82+ let(:handle) { '@pfrazee.com' }
83+84+ before do
85+ Resolv::DNS.stubs(:open).returns([])
86+ end
87+88+ it 'should also return a matching DID' do
89+ stub_request(:get, "https://pfrazee.com/.well-known/atproto-did")
90+ .to_return(body: sample_did)
91+92+ result = subject.resolve_handle(handle)
93+94+ result.should_not be_nil
95+ result.should be_a(DID)
96+ result.to_s.should == sample_did
97+ result.resolved_by.should == :http
98+ end
99+ end
100+101+ context 'when handle has a reserved TLD' do
102+ let(:handle) { 'example.test' }
103+104+ it 'should return nil' do
105+ subject.resolve_handle(handle).should be_nil
106+ end
107+ end
108+109+ context 'when a DID string is passed' do
110+ let(:handle) { BSKY_APP_DID }
111+112+ it 'should return that DID' do
113+ result = subject.resolve_handle(handle)
114+115+ result.should be_a(DID)
116+ result.to_s.should == BSKY_APP_DID
117+ end
118+ end
119+120+ context 'when a DID object is passed' do
121+ let(:handle) { DID.new(BSKY_APP_DID) }
122+123+ it 'should return a new DID object with that DID' do
124+ result = subject.resolve_handle(handle)
125+126+ result.should be_a(DID)
127+ result.to_s.should == BSKY_APP_DID
128+ result.equal?(handle).should == false
129+ end
130+ end
131+ end
132+133+ describe '#resolve_did' do
134+ context 'when passed a did:plc string' do
135+ let(:did) { 'did:plc:yk4dd2qkboz2yv6tpubpc6co' }
136+137+ it 'should return a parsed DID document object' do
138+ stub_request(:get, "https://plc.directory/#{did}")
139+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
140+141+ result = subject.resolve_did(did)
142+ result.should be_a(DIDKit::Document)
143+ result.handles.should == ['dholms.xyz']
144+ result.pds_endpoint.should == 'https://pds.dholms.xyz'
145+ end
146+147+ it 'should require a valid content type' do
148+ stub_request(:get, "https://plc.directory/#{did}")
149+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'text/plain' })
150+151+ expect { subject.resolve_did(did) }.to raise_error(DIDKit::APIError)
152+ end
153+ end
154+155+ context 'when passed a did:web string' do
156+ let(:did) { 'did:web:witchcraft.systems' }
157+158+ it 'should return a parsed DID document object' do
159+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
160+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
161+162+ result = subject.resolve_did(did)
163+ result.should be_a(DIDKit::Document)
164+ result.handles.should == ['witchcraft.systems']
165+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
166+ end
167+168+ it 'should NOT require a valid content type' do
169+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
170+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'text/plain' })
171+172+ result = subject.resolve_did(did)
173+ result.should be_a(DIDKit::Document)
174+ result.handles.should == ['witchcraft.systems']
175+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
176+ end
177+ end
178+ end
179+end
+44-5
spec/spec_helper.rb
···1# frozen_string_literal: true
23-require "didkit"
00000000045RSpec.configure do |config|
6 # Enable flags like --only-failures and --next-failure
7 config.example_status_persistence_file_path = ".rspec_status"
89- # Disable RSpec exposing methods globally on `Module` and `main`
10- config.disable_monkey_patching!
0000000000000001112- config.expect_with :rspec do |c|
13- c.syntax = :expect
00014 end
15end
000000000000
···1# frozen_string_literal: true
23+require 'simplecov'
4+5+SimpleCov.start do
6+ enable_coverage :branch
7+ add_filter "/spec/"
8+end
9+10+require 'didkit'
11+require 'json'
12+require 'webmock/rspec'
1314RSpec.configure do |config|
15 # Enable flags like --only-failures and --next-failure
16 config.example_status_persistence_file_path = ".rspec_status"
1718+ config.expect_with :rspec do |c|
19+ c.syntax = [:should, :expect]
20+ end
21+22+ config.mock_with :mocha
23+end
24+25+module SimpleCov
26+ module Formatter
27+ class HTMLFormatter
28+ def format(result)
29+ # silence the stdout summary, just save the html files
30+ unless @inline_assets
31+ Dir[File.join(@public_assets_dir, "*")].each do |path|
32+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
33+ end
34+ end
3536+ File.open(File.join(output_path, "index.html"), "wb") do |file|
37+ file.puts template("layout").result(binding)
38+ end
39+ end
40+ end
41 end
42end
43+44+BSKY_APP_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur'
45+46+WebMock.enable!
47+48+def load_did_file(name)
49+ File.read(File.join(__dir__, 'dids', name))
50+end
51+52+def load_did_json(name)
53+ JSON.parse(load_did_file(name))
54+end