···0000000000000000000000000000000000000000001## [0.2.1] - 2024-03-26
23- tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
···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
4445- tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
+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
+67-36
README.md
···1-# DIDKit
0023-A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol
0456## What does it do
···1011## Installation
1213- gem install didkit
1401516-## Usage
1718-Use the `DIDKit::Resolver` class to look up DIDs and handles.
1920-To look up a handle:
000002122```rb
23-resolver = DIDKit::Resolver.new
24-resolver.resolve_handle('nytimes.com')
25- # => #<DIDKit::DID:0x00000001035956b0 @did="did:plc:eclio37ymobqex2ncko63h4r", @type=:plc, @resolved_by=:dns>
26```
2728-This returns an object of `DIDKit::DID` class (aliased as just `DID`), which tells you:
2930- the DID as a string (`#to_s` or `#did`)
31- the DID type (`#type`, `:plc` or `:web`)
32- if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
3334-To go in the other direction โ to find an assigned and verified handle given a DID โ use `get_validated_handle` (pass DID as a string or an object):
3536```rb
37-resolver.get_validated_handle('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
38- # => "atproto.com"
39```
4041-You can also load the DID document using `resolve_did`:
4243```rb
44-doc = resolver.resolve_did('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
45- # => #<DIDKit::Document:0x0000000105d751f8 @did=#<DIDKit::DID:...>, @json={...}>
4647-doc.handles
48- # => ["pfrazee.com"]
4950-doc.pds_endpoint
51- # => "https://morel.us-east.host.bsky.network"
52```
5354-There are also some helper methods in the `DID` class that create a `Resolver` for you to save you some typing:
0005556```rb
57-did = DID.resolve_handle('jay.bsky.team')
58- # => #<DIDKit::DID:0x000000010615ed28 @did="did:plc:oky5czdrnfjpqslsw2a5iclo", @type=:plc, @resolved_by=:dns>
000005960-did.to_s
61- # => "did:plc:oky5czdrnfjpqslsw2a5iclo"
00006263-did.get_document
64- # => #<DIDKit::Document:0x00000001066d4898 @did=#<DIDKit::DID:...>, @json={...}>
00000006566-did.get_validated_handle
67- # => "jay.bsky.team"
68-```
69007071-### Configuration
007273-You can override the nameserver used for DNS lookups by setting the `nameserver` property in `Resolver`, e.g. to use Google's or CloudFlare's global DNS:
07475-```
76-resolver.nameserver = '8.8.8.8'
77```
78-7980## Credits
8182-Copyright ยฉ 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
8384The 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
···1314## Installation
1516+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/)).
1718+To install the gem, run in the command line:
1920+ [sudo] gem install didkit
2122+Or add this to your app's `Gemfile`:
2324+ 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`:
3031```rb
32+did = DID.resolve_handle('jay.bsky.team')
33+ # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo",
34+ # @resolved_by=:dns, @type=:plc>
35```
3637+This returns a `DID` object, which tells you:
3839- 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`)
4243+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`:
4445```rb
46+DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle
47+ # => "atproto.com"
48```
4950+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):
5152```rb
53+did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
05455+did.document.handles
56+ # => ["pfrazee.com"]
5758+did.document.pds_host
59+ # => "morel.us-east.host.bsky.network"
60```
6162+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:
6667```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
7576+did = DID.new('did:plc:44ybard66vv44zksje25o7dz')
77+did.account_status
78+ # => :active
79+did.account_active?
80+ # => true
81+```
8283+### 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.
86+87+Currently available options include:
88+89+- `: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:
009495+```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>
101102+resolver.resolve_did(did)
103+ # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}>
104105+resolver.get_verified_handle(did)
106+ # => 'nytimes.com'
107```
0108109## 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 ๐
···0001require_relative 'errors'
02require_relative 'resolver'
34module DIDKit
00000000005 class DID
000000000000006 def self.resolve_handle(handle)
7 Resolver.new.resolve_handle(handle)
8 end
910- attr_reader :type, :did, :resolved_by
00000000000000001112 def initialize(did, resolved_by = nil)
13- if did =~ /^did\:(\w+)\:/
000014 @did = did
15- @type = $1.to_sym
16 else
17 raise DIDError.new("Invalid DID format")
18 end
···24 @resolved_by = resolved_by
25 end
2627- alias to_s did
00000000002829 def get_document
30 Resolver.new.resolve_did(self)
31 end
3233- def get_validated_handle
34- Resolver.new.get_validated_handle(self)
0000000035 end
360000000000000000037 def web_domain
38 did.gsub(/^did\:web\:/, '') if type == :web
39 end
0000000000000000000000000000000000000000000000000000000000000004041 def ==(other)
42 if other.is_a?(String)
···1+require 'json'
2+require 'uri'
3+4require_relative 'errors'
5+require_relative 'requests'
6require_relative 'resolver'
78module DIDKit
9+10+ #
11+ # Represents a DID identifier (account on the ATProto network). This class serves as an entry
12+ # point to various lookup helpers. For convenience it can also be accessed as just `DID` without
13+ # the `DIDKit::` prefix.
14+ #
15+ # @example Resolving a handle
16+ # did = DID.resolve_handle('bsky.app')
17+ #
18+19 class DID
20+ GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
21+22+ include Requests
23+24+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
25+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
26+ #
27+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
28+ # method to pass it an input string from the user which can be a DID or handle, without having to
29+ # check which one it is.
30+ #
31+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
32+ # @return [DID, nil] resolved DID if found, nil otherwise
33+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
40+41+ # @return [String] DID identifier string
42+ attr_reader :did
43+44+ # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods
45+ attr_reader :resolved_by
46+47+ alias to_s did
48+49+50+ # Create a DID object from a DID string.
51+ #
52+ # @param did [String, DID] DID string or another DID object
53+ # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`)
54+ # @raise [DIDError] when the DID format or type is invalid
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
102103+ # 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
123+124+ # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint.
125+ #
126+ # @param request_options [Hash] request options to override
127+ # @option request_options [Integer] :timeout request timeout (default: 15)
128+ # @option request_options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
129+ #
130+ # @return [Symbol, nil] `:active`, or returned inactive status, or `nil` if account is not found
131+ # @raise [APIError] when the response is invalid
132+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
161+162+ # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint.
163+ # This is a helper which calls the {#account_status} method and checks if the status is `:active`.
164+ #
165+ # @return [Boolean] true if the returned status is active
166+ # @raise [APIError] when the response is invalid
167+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
186187 def ==(other)
188 if other.is_a?(String)
+59-18
lib/didkit/document.rb
···1require_relative 'at_handles'
02require_relative 'resolver'
3require_relative 'service_record'
4require_relative 'services'
56module DIDKit
7- class Document
8- class FormatError < StandardError
9- end
00010011 include AtHandles
12 include Services
1314- attr_reader :json, :did, :handles, :services
0000000000000000000001516 def initialize(did, json)
17 raise FormatError, "Missing id field" if json['id'].nil?
···21 @did = did
22 @json = json
2324- if service = json['service']
25- raise FormatError, "Invalid service data" unless service.is_a?(Array) && service.all? { |x| x.is_a?(Hash) }
0000000002627- @services = service.filter_map { |x|
28- id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
29- next unless id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
3031- ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
32- }
33- else
34- @services = []
35- end
3637- @handles = parse_also_known_as(json['alsoKnownAs'] || [])
38- end
0000000000003940- def get_validated_handle
41- Resolver.new.pick_valid_handle(did, handles)
42 end
43 end
44end
···1require_relative 'at_handles'
2+require_relative 'errors'
3require_relative 'resolver'
4require_relative 'service_record'
5require_relative 'services'
67module 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+ #
1415+ 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?
···47 @did = did
48 @json = json
4950+ @services = parse_services(json['service'] || [])
51+ @handles = parse_also_known_as(json['alsoKnownAs'] || [])
52+ end
53+54+ # Returns the first verified handle assigned to the DID.
55+ #
56+ # Looks up the domain handles assigned to this DID in the DID document, checks if they are
57+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
58+ # the first handle that validates correctly, or nil if none matches.
59+ #
60+ # @return [String, nil] verified handle domain, if found
6162+ def get_verified_handle
63+ Resolver.new.get_verified_handle(self)
64+ end
65000006667+ private
68+69+ 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
8182+ services
083 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
···1require 'json'
02require 'time'
3+require 'uri'
45require_relative 'plc_operation'
6+require_relative 'requests'
7+8+#
9+# NOTE: this class is pending a rewrite once new APIs are deployed to plc.directory.
10+# Things will change here in v. 0.4.
11+#
1213module DIDKit
14 class PLCImporter
15 PLC_SERVICE = 'plc.directory'
16 MAX_PAGE = 1000
1718+ include Requests
19+20+ attr_accessor :ignore_errors, :last_date, :error_handler
2122 def initialize(since: nil)
23 if since.to_s == 'beginning'
···31 @eof = true
32 end
3334+ @last_page_cids = []
35 end
3637 def plc_service
38 PLC_SERVICE
39 end
4041+ def ignore_errors=(val)
42+ @ignore_errors = val
43+44+ if val
45+ @error_handler = proc { |e, j| "(ignore error)" }
46+ else
47+ @error_handler = nil
48+ end
49+ end
50+51 def get_export(args = {})
52 url = URI("https://#{plc_service}/export")
53 url.query = URI.encode_www_form(args)
5455+ data = get_data(url, content_type: 'application/jsonlines')
56 data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) }
57 end
5859+ def fetch_audit_log(did)
60+ json = get_json("https://#{plc_service}/#{did}/log/audit", :content_type => :json)
61+ json.map { |j| PLCOperation.new(j) }
62+ end
63+64 def fetch_page
65 request_time = Time.now
6667 query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {}
68 rows = get_export(query)
6970+ operations = rows.filter_map { |json|
71 begin
72 PLCOperation.new(json)
73+ rescue PLCOperation::FormatError, AtHandles::FormatError, ServiceRecord::FormatError => e
74+ @error_handler ? @error_handler.call(e, json) : raise
75+ nil
76 end
77+ }.reject { |op|
78+ # when you pass the most recent op's timestamp to ?after, it will be returned as the first op again,
79+ # so we need to use this CID list to filter it out (so pages will usually be 999 items long)
80+81+ @last_page_cids.include?(op.cid)
82+ }
8384 @last_date = operations.last&.created_at || request_time
85+ @last_page_cids = Set.new(operations.map(&:cid))
86 @eof = (rows.length < MAX_PAGE)
8788 operations
···1require 'time'
23require_relative 'at_handles'
4+require_relative 'errors'
5require_relative 'service_record'
6require_relative 'services'
78module 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
00016 include AtHandles
17 include Services
1819+ # @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
5657 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)
6970 timestamp = json['createdAt']
71 raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
···1+require 'json'
2+require 'net/http'
3+require 'uri'
4+5+require_relative 'errors'
6+7+module DIDKit
8+9+ #
10+ # @private
11+ #
12+13+ module Requests
14+15+ private
16+17+ def get_response(url, options = {})
18+ url = URI(url) unless url.is_a?(URI)
19+20+ timeout = options[:timeout] || 15
21+22+ request_options = {
23+ use_ssl: true,
24+ open_timeout: timeout,
25+ read_timeout: timeout
26+ }
27+28+ redirects = 0
29+ visited_urls = []
30+ max_redirects = options[:max_redirects] || 5
31+32+ loop do
33+ visited_urls << url
34+35+ response = Net::HTTP.start(url.host, url.port, request_options) do |http|
36+ request = Net::HTTP::Get.new(url)
37+ http.request(request)
38+ end
39+40+ if response.is_a?(Net::HTTPRedirection) && redirects < max_redirects && (location = response['Location'])
41+ url = URI(location.include?('://') ? location : (uri_origin(url) + location))
42+43+ if visited_urls.include?(url)
44+ return response
45+ else
46+ redirects += 1
47+ end
48+ else
49+ return response
50+ end
51+ end
52+ end
53+54+ def get_data(url, options = {})
55+ content_type = options.delete(:content_type)
56+ response = get_response(url, options)
57+58+ if response.is_a?(Net::HTTPSuccess) && content_type_matches(response, content_type) && (data = response.body)
59+ data
60+ else
61+ raise APIError.new(response)
62+ end
63+ end
64+65+ def get_json(url, options = {})
66+ JSON.parse(get_data(url, options))
67+ end
68+69+ def content_type_matches(response, expected_type)
70+ content_type = response['Content-Type']
71+72+ case expected_type
73+ when String
74+ content_type == expected_type
75+ when Regexp
76+ content_type =~ expected_type
77+ when :json
78+ content_type =~ /^application\/json(;.*)?$/
79+ when nil
80+ true
81+ else
82+ raise ArgumentError, "Invalid expected_type: #{expected_type.inspect}"
83+ end
84+ end
85+86+ # backported from https://github.com/ruby/uri/pull/30/files for older Rubies
87+ def uri_origin(uri)
88+ uri = uri.is_a?(URI) ? uri : URI(uri)
89+ authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
90+91+ "#{uri.scheme}://#{authority}"
92+ end
93+ end
94+end
+99-45
lib/didkit/resolver.rb
···1-require 'json'
2-require 'open-uri'
3require 'net/http'
4require 'resolv'
56require_relative 'did'
7require_relative 'document'
089module DIDKit
0000010 class Resolver
0011 RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
12- MAX_REDIRECTS = 5
013014 attr_accessor :nameserver
150000016 def initialize(options = {})
17 @nameserver = options[:nameserver]
018 end
19000000000020 def resolve_handle(handle)
000021 domain = handle.gsub(/^@/, '')
2223 return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
···31 end
32 end
330000000034 def resolve_handle_by_dns(domain)
35- dns_records = Resolv::DNS.open(resolv_options) { |d|
36 d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
37- }
3839 if record = dns_records.first
40 if string = record.strings.first
···45 nil
46 end
4748- def resolve_handle_by_well_known(domain)
49- resolve_handle_from_url("https://#{domain}/.well-known/atproto-did")
50- end
51-52- def resolve_handle_from_url(url, redirects = 0)
53- url = URI(url) unless url.is_a?(URI)
05455- response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
56- request = Net::HTTP::Get.new(url)
57- http.request(request)
58- end
5960- if response.is_a?(Net::HTTPSuccess)
61- if text = response.body
62- return parse_did_from_well_known(text)
63- end
64- elsif response.is_a?(Net::HTTPRedirection) && redirects < MAX_REDIRECTS
65- if location = response['Location']
66- target_url = location.include?('://') ? location : (url.origin + location)
67- return resolve_handle_from_url(target_url, redirects + 1)
68- end
69 end
7071 nil
···73 nil
74 end
75000000000000000000000000000000000000000000076 def resolv_options
77 options = Resolv::DNS::Config.default_config_hash.dup
78 options[:nameserver] = nameserver if nameserver
···8586 def parse_did_from_well_known(text)
87 text = text.strip
88- text.lines.length == 1 && text =~ /\Adid\:\w+\:.*\z/ ? text : nil
89- end
90-91- def resolve_did(did)
92- did = DID.new(did) if did.is_a?(String)
93-94- did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
95 end
9697 def resolve_did_plc(did)
98- url = "https://plc.directory/#{did}"
99- json = JSON.parse(URI.open(url).read)
100 Document.new(did, json)
101 end
102103 def resolve_did_web(did)
104- url = "https://#{did.web_domain}/.well-known/did.json"
105- json = JSON.parse(URI.open(url).read)
106 Document.new(did, json)
107- end
108-109- def get_validated_handle(did_or_doc)
110- document = did_or_doc.is_a?(Document) ? did_or_doc : resolve_did(did_or_doc)
111-112- pick_valid_handle(document.did, document.handles)
113- end
114-115- def pick_valid_handle(did, handles)
116- handles.detect { |h| resolve_handle(h) == did }
117 end
118 end
119end
···001require 'net/http'
2require 'resolv'
34require_relative 'did'
5require_relative 'document'
6+require_relative 'requests'
78module 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
2021+ # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups
22 attr_accessor :nameserver
2324+ # @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
3334+ # 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(/^@/, '')
5051 return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
···59 end
60 end
6162+ # 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
7475 if record = dns_records.first
76 if string = record.strings.first
···81 nil
82 end
8384+ # 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
9192+ def resolve_handle_by_well_known(domain)
93+ url = "https://#{domain}/.well-known/atproto-did"
94+ response = get_response(url, @request_options)
09596+ if response.is_a?(Net::HTTPSuccess) && (text = response.body)
97+ return parse_did_from_well_known(text)
000000098 end
99100 nil
···102 nil
103 end
104105+ # 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
···157158 def parse_did_from_well_known(text)
159 text = text.strip
160+ text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil
000000161 end
162163 def resolve_did_plc(did)
164+ json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/)
0165 Document.new(did, json)
166 end
167168 def resolve_did_web(did)
169+ json = get_json("https://#{did.web_domain}/.well-known/did.json")
0170 Document.new(did, json)
0000000000171 end
172 end
173end
+22-1
lib/didkit/service_record.rb
···2require_relative 'errors'
34module DIDKit
00005 class ServiceRecord
6- attr_reader :key, :type, :endpoint
0000000000000000078 def initialize(key, type, endpoint)
9 begin
···2require_relative 'errors'
34module 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
2829 def initialize(key, type, endpoint)
30 begin
+53
lib/didkit/services.rb
···001module DIDKit
000002 module Services
000000003 def get_service(key, type)
4 @services&.detect { |s| s.key == key && s.type == type }
5 end
60000000007 def pds_endpoint
8 @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
9 end
100000000011 def labeler_endpoint
12 @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
13 end
00000000000000000000014 end
15end
···1+require 'uri'
2+3module 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
2122+ # 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
3435+ # 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
68end
+1-1
lib/didkit/version.rb
···1# frozen_string_literal: true
23module DIDKit
4- VERSION = "0.2.1"
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