···11-## [Unreleased]
11+## [0.3.1] - 2025-12-19
22+33+- allow passing a DID string or object to `#resolve_handle` and just return that DID โ so you can have a script that accepts either a handle or a DID, and passes the input to `DID.resolve_handle` without checking which one it is
44+- allow passing another DID object to `DID.new` and return a copy of that DID
55+- parse `seq` field in `PLCOperation` if included and expose it as a property
66+- fixed some errors on Rubies older than 3.2 due to missing `filter_map` and `URI#origin`
77+- `PLCOperation` verifies if the argument is a `Hash`
88+99+## [0.3.0] - 2025-12-15
1010+1111+Breaking changes:
1212+1313+* removed `DID#is_known_by_relay?` โ it doesn't work anymore, since relays are now non-archival and they expose almost no XRPC routes
1414+* renamed a few handle-related methods:
1515+ - `get_validated_handle` -> `get_verified_handle`
1616+ - `pick_valid_handle` -> `first_verified_handle`
1717+1818+Also:
1919+2020+- added `DID#account_status` method, which checks `getRepoStatus` endpoint to tell if an account is active, deactivated, taken down etc.
2121+- added `DID#account_active?` helper (`account_status == :active`)
2222+- `DID#account_exists?` now calls `getRepoStatus` (via `account_status`, checking if it's not nil) instead of `getLatestCommit`
2323+- added `DID#document` which keeps a memoized copy of the document
2424+- added `pds_host` & `labeler_host` methods to `PLCOperation` and `Document`, which return the PDS/labeller address without the `https://`
2525+- added `labeller_endpoint` & `labeller_host` aliases for the double-L enjoyers :]
2626+- added `PLCOperation#cid`
2727+- `PLCImporter` now removes duplicate operations at the edge of pages returned from the `/export` API
2828+- rewritten some networking code โ all classes now use `Net::HTTP` with consistent options instead of `open-uri`
2929+3030+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.
3131+3232+## [0.2.3] - 2024-07-02
3333+3434+- added a `DID#get_audit_log` method that fetches the PLC audit log for a DID
3535+- added a way to set an error handler in `PLCImporter`
3636+- 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
3737+- minor bug fixes
3838+3939+## [0.2.2] - 2024-04-01
4040+4141+- added helpers for checking if a DID is known by (federated with) a relay or if the repo exists on its assigned PDS
4242+4343+## [0.2.1] - 2024-03-26
4444+4545+- tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
4646+- added Ruby stdlib dependencies explicitly to the gemspec
4747+4848+## [0.2.0] - 2024-03-19
4949+5050+- added `PLCImporter` class, which lets you import operations from PLC in pages of 1000 through the "export" API
5151+- implemented parsing of all services from DID doc & operations, not only `atproto_pds` (specifically labeller endpoints)
5252+- allow setting the nameserver in `Resolver` initializer
5353+5454+## [0.1.0] - 2024-03-12
2555656+- rejecting handles from disallowed domains like `.arpa` or `.test`
5757+- validating handles with the `.well-known` file having a trailing newline
5858+- validating handles with `.well-known` address returning a redirect
5959+- added `#pick_valid_handle` helper
6060+- allow overriding the nameserver for `Resolv::DNS`
6161+- other bug fixes
6262+6363+## [0.0.4] - 2024-03-07
6464+6565+- extracted resolving code from `DID` to a new `Resolver` class (`DID` has helper methods to call the resolver)
6666+- added `Resolver#get_validated_handle` method to validate handles from the `Document` (+ helpers in `DID` in `Document`)
6767+- added timeout to `#resolve_handle_by_well_known`
6868+6969+## [0.0.3] - 2024-03-06
7070+7171+- added `Document#handles` with handle info extracted from `alsoKnownAs` field
7272+- added validation of various fields of the DID document
7373+- added `DID#resolved_by` (`:dns` or `:http`)
7474+- added `DID#did` which returns the DID in a string form like `to_s`
7575+- added `DID#web_domain` which returns the domain part of a `did:web`
7676+- changed `DID#type` to be stored as a symbol
7777+7878+## [0.0.2] - 2023-11-14
7979+8080+- fixed missing require
8181+- fixed some connection error handling
8282+8383+## [0.0.1] - 2023-11-14
8484+8585+Initial release:
8686+8787+- resolving handle to DID via DNS or HTTP well-known
8888+- loading DID document via PLC or did:web well-known
8989+- extracting PDS endpoint field from the DID doc
···11The zlib License
2233-Copyright (c) 2023 Jakub Suder
33+Copyright (c) 2026 Jakub Suder
4455This software is provided 'as-is', without any express or implied
66warranty. In no event will the authors be held liable for any damages
+98-14
README.md
···11-# Didkit
11+# DIDKit ๐ชช
2233-TODO: Delete this and the text below, and describe your gem
33+A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol.
4455-Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/didkit`. To experiment with that code, run `bin/console` for an interactive prompt.
55+> [!NOTE]
66+> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
77+88+99+## What does it do
1010+1111+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.
1212+613714## Installation
81599-TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
1616+To use DIDKit, you need a reasonably new version of Ruby โ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
10171111-Install the gem and add to the application's Gemfile by executing:
1818+To install the gem, run in the command line:
12191313- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
2020+ [sudo] gem install didkit
14211515-If bundler is not being used to manage dependencies, install the gem by executing:
2222+Or add this to your app's `Gemfile`:
16231717- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
2424+ gem 'didkit', '~> 0.3'
2525+18261927## Usage
20282121-TODO: Write usage instructions here
2929+The simplest way to use the gem is through the `DIDKit::DID` class, aliased as just `DID`:
22302323-## Development
3131+```rb
3232+did = DID.resolve_handle('jay.bsky.team')
3333+ # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo",
3434+ # @resolved_by=:dns, @type=:plc>
3535+```
24362525-After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
3737+This returns a `DID` object, which tells you:
26382727-To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
3939+- the DID as a string (`#to_s` or `#did`)
4040+- the DID type (`#type`, `:plc` or `:web`)
4141+- if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
28422929-## Contributing
4343+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`:
30443131-Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/didkit.
4545+```rb
4646+DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle
4747+ # => "atproto.com"
4848+```
4949+5050+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):
5151+5252+```rb
5353+did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
5454+5555+did.document.handles
5656+ # => ["pfrazee.com"]
5757+5858+did.document.pds_host
5959+ # => "morel.us-east.host.bsky.network"
6060+```
6161+6262+6363+### Checking account status
6464+6565+`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:
6666+6767+```rb
6868+did = DID.new('did:plc:ch7azdejgddtlijyzurfdihn')
6969+did.account_status
7070+ # => :takendown
7171+did.account_active?
7272+ # => false
7373+did.account_exists?
7474+ # => true
7575+7676+did = DID.new('did:plc:44ybard66vv44zksje25o7dz')
7777+did.account_status
7878+ # => :active
7979+did.account_active?
8080+ # => true
8181+```
8282+8383+### Configuration
8484+8585+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.
8686+8787+Currently available options include:
8888+8989+- `:nameserver` - override the nameserver used for DNS lookups, e.g. to use Google's or CloudFlare's DNS
9090+- `:timeout` - change the connection/response timeout for HTTP requests (default: 15 s)
9191+- `:max_redirects` - change allowed maximum number of redirects (default: 5)
9292+9393+Example:
9494+9595+```rb
9696+resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8', timeout: 30)
9797+9898+did = resolver.resolve_handle('nytimes.com')
9999+ # => #<DIDKit::DID:0x0... @did="did:plc:eclio37ymobqex2ncko63h4r",
100100+ # @resolved_by=:dns, @type=:plc>
101101+102102+resolver.resolve_did(did)
103103+ # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}>
104104+105105+resolver.get_verified_handle(did)
106106+ # => 'nytimes.com'
107107+```
108108+109109+## Credits
110110+111111+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
112112+113113+The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
114114+115115+Bug reports and pull requests are welcome ๐
···11require 'json'
22-require 'open-uri'
33-require 'resolv'
22+require 'uri'
4355-require_relative 'document'
64require_relative 'errors'
55+require_relative 'requests'
66+require_relative 'resolver'
7788module DIDKit
99+1010+ #
1111+ # Represents a DID identifier (account on the ATProto network). This class serves as an entry
1212+ # point to various lookup helpers. For convenience it can also be accessed as just `DID` without
1313+ # the `DIDKit::` prefix.
1414+ #
1515+ # @example Resolving a handle
1616+ # did = DID.resolve_handle('bsky.app')
1717+ #
1818+919 class DID
1010- def self.resolve_handle(handle)
1111- domain = handle.gsub(/^@/, '')
2020+ GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
12211313- if dns_did = resolve_handle_by_dns(domain)
1414- DID.new(dns_did)
1515- elsif http_did = resolve_handle_by_well_known(domain)
1616- DID.new(http_did)
1717- else
1818- nil
1919- end
2222+ include Requests
2323+2424+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
2525+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
2626+ #
2727+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
2828+ # method to pass it an input string from the user which can be a DID or handle, without having to
2929+ # check which one it is.
3030+ #
3131+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
3232+ # @return [DID, nil] resolved DID if found, nil otherwise
3333+3434+ def self.resolve_handle(handle)
3535+ Resolver.new.resolve_handle(handle)
2036 end
21372222- def self.resolve_handle_by_dns(domain)
2323- dns_records = Resolv::DNS.open { |d| d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) }
3838+ # @return [Symbol] DID type (`:plc` or `:web`)
3939+ attr_reader :type
24402525- if record = dns_records.first
2626- if string = record.strings.first
2727- if string =~ /^did\=(did\:\w+\:.*)$/
2828- return $1
2929- end
3030- end
3131- end
4141+ # @return [String] DID identifier string
4242+ attr_reader :did
32433333- nil
3434- end
4444+ # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods
4545+ attr_reader :resolved_by
35463636- def self.resolve_handle_by_well_known(domain)
3737- url = URI("https://#{domain}/.well-known/atproto-did")
3838- response = Net::HTTP.get_response(url)
4747+ alias to_s did
39484040- if response.is_a?(Net::HTTPSuccess)
4141- if text = response.body
4242- if text.lines.length == 1 && text.start_with?('did:')
4343- return text
4444- end
4545- end
4646- end
47494848- nil
4949- end
5050+ # Create a DID object from a DID string.
5151+ #
5252+ # @param did [String, DID] DID string or another DID object
5353+ # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`)
5454+ # @raise [DIDError] when the DID format or type is invalid
50555151- attr_reader :type
5656+ def initialize(did, resolved_by = nil)
5757+ if did.is_a?(DID)
5858+ did = did.to_s
5959+ end
52605353- def initialize(did)
5454- if did =~ /^did\:(\w+)\:/
6161+ if did =~ GENERIC_REGEXP
5562 @did = did
5656- @type = $1
6363+ @type = did.split(':')[1].to_sym
5764 else
5865 raise DIDError.new("Invalid DID format")
5966 end
60676161- if @type != 'plc' && @type != 'web'
6868+ if @type != :plc && @type != :web
6269 raise DIDError.new("Unrecognized DID type: #{@type}")
6370 end
7171+7272+ @resolved_by = resolved_by
6473 end
65746666- def to_s
6767- @did
7575+ # Returns or looks up the DID document with the DID's identity details from an appropriate source.
7676+ # This method caches the document in a local variable if it's called again.
7777+ #
7878+ # @return [Document] resolved DID document
7979+8080+ def document
8181+ @document ||= get_document
6882 end
69838484+ # Looks up the DID document with the DID's identity details from an appropriate source.
8585+ # @return [Document] resolved DID document
8686+7087 def get_document
7171- if @type == 'plc'
7272- resolve_did_plc(@did)
7373- elsif @type == 'web'
7474- resolve_did_web(@did)
8888+ Resolver.new.resolve_did(self)
8989+ end
9090+9191+ # Returns the first verified handle assigned to this DID.
9292+ #
9393+ # Looks up the domain handles assigned to this DID in its DID document, checks if they are
9494+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
9595+ # the first handle that validates correctly, or nil if none matches.
9696+ #
9797+ # @return [String, nil] verified handle domain, if found
9898+9999+ def get_verified_handle
100100+ Resolver.new.get_verified_handle(document)
101101+ end
102102+103103+ # Fetches the PLC audit log (list of all previous operations) for a did:plc DID.
104104+ #
105105+ # @return [Array<PLCOperation>] list of PLC operations in the audit log
106106+ # @raise [DIDError] when the DID is not a did:plc
107107+108108+ def get_audit_log
109109+ if @type == :plc
110110+ PLCImporter.new.fetch_audit_log(self)
111111+ else
112112+ raise DIDError.new("Audit log not supported for did:#{@type}")
75113 end
76114 end
771157878- def resolve_did_plc(did)
7979- url = "https://plc.directory/#{did}"
8080- json = JSON.parse(URI.open(url).read)
8181- Document.new(json)
116116+ # Returns the domain portion of a did:web identifier.
117117+ #
118118+ # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc
119119+120120+ def web_domain
121121+ did.gsub(/^did\:web\:/, '') if type == :web
82122 end
831238484- def resolve_did_web(did)
8585- host = did.gsub(/^did\:web\:/, '')
8686- url = "https://#{host}/.well-known/did.json"
8787- json = JSON.parse(URI.open(url).read)
8888- Document.new(json)
124124+ # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint.
125125+ #
126126+ # @param request_options [Hash] request options to override
127127+ # @option request_options [Integer] :timeout request timeout (default: 15)
128128+ # @option request_options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
129129+ #
130130+ # @return [Symbol, nil] `:active`, or returned inactive status, or `nil` if account is not found
131131+ # @raise [APIError] when the response is invalid
132132+133133+ def account_status(request_options = {})
134134+ doc = self.document
135135+ return nil if doc.pds_endpoint.nil?
136136+137137+ pds_host = uri_origin(doc.pds_endpoint)
138138+ url = URI("#{pds_host}/xrpc/com.atproto.sync.getRepoStatus")
139139+ url.query = URI.encode_www_form(:did => @did)
140140+141141+ response = get_response(url, request_options)
142142+ status = response.code.to_i
143143+ is_json = (response['Content-Type'] =~ /^application\/json(;.*)?$/)
144144+145145+ if status == 200 && is_json
146146+ json = JSON.parse(response.body)
147147+148148+ if json['active'] == true
149149+ :active
150150+ elsif json['active'] == false && json['status'].is_a?(String) && json['status'].length <= 100
151151+ json['status'].to_sym
152152+ else
153153+ raise APIError.new(response)
154154+ end
155155+ elsif status == 400 && is_json && JSON.parse(response.body)['error'] == 'RepoNotFound'
156156+ nil
157157+ else
158158+ raise APIError.new(response)
159159+ end
160160+ end
161161+162162+ # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint.
163163+ # This is a helper which calls the {#account_status} method and checks if the status is `:active`.
164164+ #
165165+ # @return [Boolean] true if the returned status is active
166166+ # @raise [APIError] when the response is invalid
167167+168168+ def account_active?
169169+ account_status == :active
170170+ end
171171+172172+ # Checks if the account exists its own PDS, using the `getRepoStatus` endpoint.
173173+ # This is a helper which calls the {#account_status} method and checks if the repo is found at all.
174174+ #
175175+ # @return [Boolean] true if the returned status is valid, false if repo is not found
176176+ # @raise [APIError] when the response is invalid
177177+178178+ def account_exists?
179179+ account_status != nil
180180+ end
181181+182182+ # Compares the DID to another DID object or string.
183183+ #
184184+ # @param other [DID, String] other DID to compare with
185185+ # @return [Boolean] true if it's the same DID
186186+187187+ def ==(other)
188188+ if other.is_a?(String)
189189+ self.did == other
190190+ elsif other.is_a?(DID)
191191+ self.did == other.did
192192+ else
193193+ false
194194+ end
89195 end
90196 end
91197end
+75-4
lib/didkit/document.rb
···11+require_relative 'at_handles'
22+require_relative 'errors'
33+require_relative 'resolver'
44+require_relative 'service_record'
55+require_relative 'services'
66+17module DIDKit
88+99+ #
1010+ # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain.
1111+ #
1212+ # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object.
1313+ #
1414+215 class Document
1616+ include AtHandles
1717+ include Services
1818+1919+ # @return [Hash] the complete JSON data of the DID document
320 attr_reader :json
42155- def initialize(json)
2222+ # @return [DID] the DID that this document describes
2323+ attr_reader :did
2424+2525+ # Returns a list of handles assigned to this DID in its DID document.
2626+ #
2727+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
2828+ # Use {#get_verified_handle} to find a handle that is correctly verified.
2929+ #
3030+ # @return [Array<String>]
3131+ attr_reader :handles
3232+3333+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
3434+ attr_reader :services
3535+3636+ # Creates a DID document object.
3737+ #
3838+ # @param did [DID] DID object
3939+ # @param json [Hash] DID document JSON
4040+ # @raise [FormatError] when required fields are missing or invalid.
4141+4242+ def initialize(did, json)
4343+ raise FormatError, "Missing id field" if json['id'].nil?
4444+ raise FormatError, "Invalid id field" unless json['id'].is_a?(String)
4545+ raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s
4646+4747+ @did = did
648 @json = json
4949+5050+ @services = parse_services(json['service'] || [])
5151+ @handles = parse_also_known_as(json['alsoKnownAs'] || [])
752 end
85399- def pds_endpoint
1010- service = (@json['service'] || []).detect { |s| s['id'] == '#atproto_pds' }
1111- service && service['serviceEndpoint']
5454+ # Returns the first verified handle assigned to the DID.
5555+ #
5656+ # Looks up the domain handles assigned to this DID in the DID document, checks if they are
5757+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
5858+ # the first handle that validates correctly, or nil if none matches.
5959+ #
6060+ # @return [String, nil] verified handle domain, if found
6161+6262+ def get_verified_handle
6363+ Resolver.new.get_verified_handle(self)
6464+ end
6565+6666+6767+ private
6868+6969+ def parse_services(service_data)
7070+ raise FormatError, "Invalid service data" unless service_data.is_a?(Array) && service_data.all? { |x| x.is_a?(Hash) }
7171+7272+ services = []
7373+7474+ service_data.each do |x|
7575+ id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
7676+7777+ if id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
7878+ services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
7979+ end
8080+ end
8181+8282+ services
1283 end
1384 end
1485end
+35
lib/didkit/errors.rb
···11module DIDKit
22+33+ #
44+ # Raised when an HTTP request returns a response with an error status.
55+ #
66+ class APIError < StandardError
77+88+ # @return [Net::HTTPResponse] the returned HTTP response
99+ attr_reader :response
1010+1111+ # @param response [Net::HTTPResponse] the returned HTTP response
1212+ def initialize(response)
1313+ @response = response
1414+ super("APIError: #{response}")
1515+ end
1616+1717+ # @return [Integer] HTTP status code
1818+ def status
1919+ response.code.to_i
2020+ end
2121+2222+ # @return [String] HTTP response body
2323+ def body
2424+ response.body
2525+ end
2626+ end
2727+2828+ #
2929+ # Raised when a string is not a valid DID or not of the right type.
3030+ #
231 class DIDError < StandardError
3232+ end
3333+3434+ #
3535+ # Raised when the loaded data has some missing or invalid fields.
3636+ #
3737+ class FormatError < StandardError
338 end
439end
+103
lib/didkit/plc_importer.rb
···11+require 'json'
22+require 'time'
33+require 'uri'
44+55+require_relative 'plc_operation'
66+require_relative 'requests'
77+88+#
99+# NOTE: this class is pending a rewrite once new APIs are deployed to plc.directory.
1010+# Things will change here in v. 0.4.
1111+#
1212+1313+module DIDKit
1414+ class PLCImporter
1515+ PLC_SERVICE = 'plc.directory'
1616+ MAX_PAGE = 1000
1717+1818+ include Requests
1919+2020+ attr_accessor :ignore_errors, :last_date, :error_handler
2121+2222+ def initialize(since: nil)
2323+ if since.to_s == 'beginning'
2424+ @last_date = nil
2525+ elsif since.is_a?(String)
2626+ @last_date = Time.parse(since)
2727+ elsif since
2828+ @last_date = since
2929+ else
3030+ @last_date = Time.now
3131+ @eof = true
3232+ end
3333+3434+ @last_page_cids = []
3535+ end
3636+3737+ def plc_service
3838+ PLC_SERVICE
3939+ end
4040+4141+ def ignore_errors=(val)
4242+ @ignore_errors = val
4343+4444+ if val
4545+ @error_handler = proc { |e, j| "(ignore error)" }
4646+ else
4747+ @error_handler = nil
4848+ end
4949+ end
5050+5151+ def get_export(args = {})
5252+ url = URI("https://#{plc_service}/export")
5353+ url.query = URI.encode_www_form(args)
5454+5555+ data = get_data(url, content_type: 'application/jsonlines')
5656+ data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) }
5757+ end
5858+5959+ def fetch_audit_log(did)
6060+ json = get_json("https://#{plc_service}/#{did}/log/audit", :content_type => :json)
6161+ json.map { |j| PLCOperation.new(j) }
6262+ end
6363+6464+ def fetch_page
6565+ request_time = Time.now
6666+6767+ query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {}
6868+ rows = get_export(query)
6969+7070+ operations = rows.filter_map { |json|
7171+ begin
7272+ PLCOperation.new(json)
7373+ rescue PLCOperation::FormatError, AtHandles::FormatError, ServiceRecord::FormatError => e
7474+ @error_handler ? @error_handler.call(e, json) : raise
7575+ nil
7676+ end
7777+ }.reject { |op|
7878+ # when you pass the most recent op's timestamp to ?after, it will be returned as the first op again,
7979+ # so we need to use this CID list to filter it out (so pages will usually be 999 items long)
8080+8181+ @last_page_cids.include?(op.cid)
8282+ }
8383+8484+ @last_date = operations.last&.created_at || request_time
8585+ @last_page_cids = Set.new(operations.map(&:cid))
8686+ @eof = (rows.length < MAX_PAGE)
8787+8888+ operations
8989+ end
9090+9191+ def fetch(&block)
9292+ loop do
9393+ operations = fetch_page
9494+ block.call(operations)
9595+ break if eof?
9696+ end
9797+ end
9898+9999+ def eof?
100100+ !!@eof
101101+ end
102102+ end
103103+end
+104
lib/didkit/plc_operation.rb
···11+require 'time'
22+33+require_relative 'at_handles'
44+require_relative 'errors'
55+require_relative 'service_record'
66+require_relative 'services'
77+88+module DIDKit
99+1010+ #
1111+ # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory)
1212+ # (e.g. changing assigned handles or migrating to a different PDS).
1313+ #
1414+1515+ class PLCOperation
1616+ include AtHandles
1717+ include Services
1818+1919+ # @return [Hash] the JSON from which the operation is parsed
2020+ attr_reader :json
2121+2222+ # @return [String] the DID which the operation concerns
2323+ attr_reader :did
2424+2525+ # @return [String] CID (Content Identifier) of the operation
2626+ attr_reader :cid
2727+2828+ # Returns a sequential number of the operation (only used in the new export API).
2929+ # @return [Integer, nil] sequential number of the operation
3030+ attr_reader :seq
3131+3232+ # @return [Time] time when the operation was created
3333+ attr_reader :created_at
3434+3535+ # Returns the `type` field of the operation (usually `"plc_operation"`).
3636+ # @return [String] the operation type
3737+ attr_reader :type
3838+3939+ # Returns a list of handles assigned to the DID in this operation.
4040+ #
4141+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
4242+ # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is
4343+ # correctly verified.
4444+ #
4545+ # @return [Array<String>]
4646+ attr_reader :handles
4747+4848+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
4949+ attr_reader :services
5050+5151+5252+ # Creates a PLCOperation object.
5353+ #
5454+ # @param json [Hash] operation JSON
5555+ # @raise [FormatError] when required fields are missing or invalid
5656+5757+ def initialize(json)
5858+ @json = json
5959+ raise FormatError, "Expected argument to be a Hash, got a #{json.class}" unless @json.is_a?(Hash)
6060+6161+ @seq = json['seq']
6262+ @did = json['did']
6363+ raise FormatError, "Missing DID: #{json}" if @did.nil?
6464+ raise FormatError, "Invalid DID: #{@did.inspect}" unless @did.is_a?(String) && @did.start_with?('did:')
6565+6666+ @cid = json['cid']
6767+ raise FormatError, "Missing CID: #{json}" if @cid.nil?
6868+ raise FormatError, "Invalid CID: #{@cid}" unless @cid.is_a?(String)
6969+7070+ timestamp = json['createdAt']
7171+ raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
7272+ raise FormatError, "Invalid createdAt: #{timestamp.inspect}" unless timestamp.is_a?(String)
7373+7474+ @created_at = Time.parse(timestamp)
7575+7676+ operation = json['operation']
7777+ raise FormatError, "Missing operation key: #{json}" if operation.nil?
7878+ raise FormatError, "Invalid operation data: #{operation.inspect}" unless operation.is_a?(Hash)
7979+8080+ type = operation['type']
8181+ raise FormatError, "Missing operation type: #{json}" if type.nil?
8282+8383+ @type = type.to_sym
8484+ return unless @type == :plc_operation
8585+8686+ services = operation['services']
8787+ raise FormatError, "Missing services key: #{json}" if services.nil?
8888+ raise FormatError, "Invalid services data: #{services}" unless services.is_a?(Hash)
8989+9090+ @services = services.map { |k, x|
9191+ type, endpoint = x.values_at('type', 'endpoint')
9292+9393+ raise FormatError, "Missing service type" unless type
9494+ raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String)
9595+ raise FormatError, "Missing service endpoint" unless endpoint
9696+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String)
9797+9898+ ServiceRecord.new(k, type, endpoint)
9999+ }
100100+101101+ @handles = parse_also_known_as(operation['alsoKnownAs'])
102102+ end
103103+ end
104104+end
+94
lib/didkit/requests.rb
···11+require 'json'
22+require 'net/http'
33+require 'uri'
44+55+require_relative 'errors'
66+77+module DIDKit
88+99+ #
1010+ # @private
1111+ #
1212+1313+ module Requests
1414+1515+ private
1616+1717+ def get_response(url, options = {})
1818+ url = URI(url) unless url.is_a?(URI)
1919+2020+ timeout = options[:timeout] || 15
2121+2222+ request_options = {
2323+ use_ssl: true,
2424+ open_timeout: timeout,
2525+ read_timeout: timeout
2626+ }
2727+2828+ redirects = 0
2929+ visited_urls = []
3030+ max_redirects = options[:max_redirects] || 5
3131+3232+ loop do
3333+ visited_urls << url
3434+3535+ response = Net::HTTP.start(url.host, url.port, request_options) do |http|
3636+ request = Net::HTTP::Get.new(url)
3737+ http.request(request)
3838+ end
3939+4040+ if response.is_a?(Net::HTTPRedirection) && redirects < max_redirects && (location = response['Location'])
4141+ url = URI(location.include?('://') ? location : (uri_origin(url) + location))
4242+4343+ if visited_urls.include?(url)
4444+ return response
4545+ else
4646+ redirects += 1
4747+ end
4848+ else
4949+ return response
5050+ end
5151+ end
5252+ end
5353+5454+ def get_data(url, options = {})
5555+ content_type = options.delete(:content_type)
5656+ response = get_response(url, options)
5757+5858+ if response.is_a?(Net::HTTPSuccess) && content_type_matches(response, content_type) && (data = response.body)
5959+ data
6060+ else
6161+ raise APIError.new(response)
6262+ end
6363+ end
6464+6565+ def get_json(url, options = {})
6666+ JSON.parse(get_data(url, options))
6767+ end
6868+6969+ def content_type_matches(response, expected_type)
7070+ content_type = response['Content-Type']
7171+7272+ case expected_type
7373+ when String
7474+ content_type == expected_type
7575+ when Regexp
7676+ content_type =~ expected_type
7777+ when :json
7878+ content_type =~ /^application\/json(;.*)?$/
7979+ when nil
8080+ true
8181+ else
8282+ raise ArgumentError, "Invalid expected_type: #{expected_type.inspect}"
8383+ end
8484+ end
8585+8686+ # backported from https://github.com/ruby/uri/pull/30/files for older Rubies
8787+ def uri_origin(uri)
8888+ uri = uri.is_a?(URI) ? uri : URI(uri)
8989+ authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
9090+9191+ "#{uri.scheme}://#{authority}"
9292+ end
9393+ end
9494+end
+173
lib/didkit/resolver.rb
···11+require 'net/http'
22+require 'resolv'
33+44+require_relative 'did'
55+require_relative 'document'
66+require_relative 'requests'
77+88+module DIDKit
99+1010+ #
1111+ # A class which manages resolving of handles to DIDs and DIDs to DID documents.
1212+ #
1313+1414+ class Resolver
1515+ # These TLDs are not allowed in ATProto handles, so the resolver returns nil for them
1616+ # without trying to look them up.
1717+ RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
1818+1919+ include Requests
2020+2121+ # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups
2222+ attr_accessor :nameserver
2323+2424+ # @param options [Hash] resolver options
2525+ # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs)
2626+ # @option options [Integer] :timeout request timeout in seconds (default: 15)
2727+ # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
2828+2929+ def initialize(options = {})
3030+ @nameserver = options[:nameserver]
3131+ @request_options = options.slice(:timeout, :max_redirects)
3232+ end
3333+3434+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
3535+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
3636+ #
3737+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
3838+ # method to pass it an input string from the user which can be a DID or handle, without having to
3939+ # check which one it is.
4040+ #
4141+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
4242+ # @return [DID, nil] resolved DID if found, nil otherwise
4343+4444+ def resolve_handle(handle)
4545+ if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP
4646+ return DID.new(handle)
4747+ end
4848+4949+ domain = handle.gsub(/^@/, '')
5050+5151+ return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
5252+5353+ if dns_did = resolve_handle_by_dns(domain)
5454+ DID.new(dns_did, :dns)
5555+ elsif http_did = resolve_handle_by_well_known(domain)
5656+ DID.new(http_did, :http)
5757+ else
5858+ nil
5959+ end
6060+ end
6161+6262+ # Tries to resolve a handle into DID using the DNS TXT method.
6363+ #
6464+ # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is
6565+ # a correct DID string.
6666+ #
6767+ # @param domain [String] a domain handle to look up
6868+ # @return [String, nil] resolved DID if found, nil otherwise
6969+7070+ def resolve_handle_by_dns(domain)
7171+ dns_records = Resolv::DNS.open(resolv_options) do |d|
7272+ d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
7373+ end
7474+7575+ if record = dns_records.first
7676+ if string = record.strings.first
7777+ return parse_did_from_dns(string)
7878+ end
7979+ end
8080+8181+ nil
8282+ end
8383+8484+ # Tries to resolve a handle into DID using the HTTP .well-known method.
8585+ #
8686+ # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns
8787+ # a text file that contains a correct DID string.
8888+ #
8989+ # @param domain [String] a domain handle to look up
9090+ # @return [String, nil] resolved DID if found, nil otherwise
9191+9292+ def resolve_handle_by_well_known(domain)
9393+ url = "https://#{domain}/.well-known/atproto-did"
9494+ response = get_response(url, @request_options)
9595+9696+ if response.is_a?(Net::HTTPSuccess) && (text = response.body)
9797+ return parse_did_from_well_known(text)
9898+ end
9999+100100+ nil
101101+ rescue StandardError => e
102102+ nil
103103+ end
104104+105105+ # Resolve a DID to a DID document.
106106+ #
107107+ # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
108108+ # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs.
109109+ #
110110+ # @param did [String, DID] DID string or object
111111+ # @return [Document] resolved DID document
112112+ # @raise [APIError] if an incorrect response is returned
113113+114114+ def resolve_did(did)
115115+ did = DID.new(did) if did.is_a?(String)
116116+117117+ did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
118118+ end
119119+120120+ # Returns the first verified handle assigned to the given DID.
121121+ #
122122+ # Looks up the domain handles assigned to the DID in the DID document, checks if they are
123123+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
124124+ # the first handle that validates correctly, or nil if none matches.
125125+ #
126126+ # @param subject [String, DID, Document] a DID or its DID document
127127+ # @return [String, nil] verified handle domain, if found
128128+129129+ def get_verified_handle(subject)
130130+ document = subject.is_a?(Document) ? subject : resolve_did(subject)
131131+132132+ first_verified_handle(document.did, document.handles)
133133+ end
134134+135135+ # Returns the first handle from the list that resolves back to the given DID.
136136+ #
137137+ # @param did [DID, String] DID to verify the handles against
138138+ # @param handles [Array<String>] handles to check
139139+ # @return [String, nil] a verified handle, if found
140140+141141+ def first_verified_handle(did, handles)
142142+ handles.detect { |h| resolve_handle(h) == did.to_s }
143143+ end
144144+145145+146146+ private
147147+148148+ def resolv_options
149149+ options = Resolv::DNS::Config.default_config_hash.dup
150150+ options[:nameserver] = nameserver if nameserver
151151+ options
152152+ end
153153+154154+ def parse_did_from_dns(txt)
155155+ txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil
156156+ end
157157+158158+ def parse_did_from_well_known(text)
159159+ text = text.strip
160160+ text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil
161161+ end
162162+163163+ def resolve_did_plc(did)
164164+ json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/)
165165+ Document.new(did, json)
166166+ end
167167+168168+ def resolve_did_web(did)
169169+ json = get_json("https://#{did.web_domain}/.well-known/did.json")
170170+ Document.new(did, json)
171171+ end
172172+ end
173173+end
+41
lib/didkit/service_record.rb
···11+require 'uri'
22+require_relative 'errors'
33+44+module DIDKit
55+66+ # A parsed service record from either a DID document's `service` field or a PLC directory
77+ # operation's `services` field.
88+99+ class ServiceRecord
1010+1111+ # Returns the service's identifier (without `#`), like "atproto_pds".
1212+ # @return [String] service's identifier
1313+ attr_reader :key
1414+1515+ # Returns the service's type field, like "AtprotoPersonalDataServer".
1616+ # @return [String] service's type
1717+ attr_reader :type
1818+1919+ # @return [String] service's endpoint URL
2020+ attr_reader :endpoint
2121+2222+ # Create a service record from DID document fields.
2323+ #
2424+ # @param key [String] service identifier (without `#`)
2525+ # @param type [String] service type
2626+ # @param endpoint [String] service endpoint URL
2727+ # @raise [FormatError] when the endpoint is not a valid URI
2828+2929+ def initialize(key, type, endpoint)
3030+ begin
3131+ uri = URI(endpoint)
3232+ rescue URI::Error
3333+ raise FormatError, "Invalid service endpoint: #{endpoint.inspect}"
3434+ end
3535+3636+ @key = key
3737+ @type = type
3838+ @endpoint = endpoint
3939+ end
4040+ end
4141+end
+68
lib/didkit/services.rb
···11+require 'uri'
22+33+module DIDKit
44+55+ #
66+ # @api private
77+ #
88+99+ module Services
1010+1111+ # Finds a service entry matching the given key and type.
1212+ #
1313+ # @api public
1414+ # @param key [String] service key in the DID document
1515+ # @param type [String] service type identifier
1616+ # @return [ServiceRecord, nil] matching service record, if found
1717+1818+ def get_service(key, type)
1919+ @services&.detect { |s| s.key == key && s.type == type }
2020+ end
2121+2222+ # Returns the PDS service endpoint, if present.
2323+ #
2424+ # If the DID has an `#atproto_pds` service declared in its `service` section,
2525+ # returns the URL in its `serviceEndpoint` field. In other words, this is the URL
2626+ # of the PDS assigned to a given user, which stores the user's account and repo.
2727+ #
2828+ # @api public
2929+ # @return [String, nil] PDS service endpoint URL
3030+3131+ def pds_endpoint
3232+ @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
3333+ end
3434+3535+ # Returns the labeler service endpoint, if present.
3636+ #
3737+ # If the DID has an `#atproto_labeler` service declared in its `service` section,
3838+ # returns the URL in its `serviceEndpoint` field.
3939+ #
4040+ # @api public
4141+ # @return [String, nil] labeler service endpoint URL
4242+4343+ def labeler_endpoint
4444+ @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
4545+ end
4646+4747+ # Returns the hostname of the PDS service, if present.
4848+ #
4949+ # @api public
5050+ # @return [String, nil] hostname of the PDS endpoint URL
5151+5252+ def pds_host
5353+ pds_endpoint&.then { |x| URI(x).host }
5454+ end
5555+5656+ # Returns the hostname of the labeler service, if present.
5757+ #
5858+ # @api public
5959+ # @return [String, nil] hostname of the labeler endpoint URL
6060+6161+ def labeler_host
6262+ labeler_endpoint&.then { |x| URI(x).host }
6363+ end
6464+6565+ alias labeller_endpoint labeler_endpoint
6666+ alias labeller_host labeler_host
6767+ end
6868+end
+1-1
lib/didkit/version.rb
···11# frozen_string_literal: true
2233module DIDKit
44- VERSION = "0.0.1"
44+ VERSION = "0.3.1"
55end
···11+describe DIDKit::DID do
22+ subject { described_class }
33+44+ let(:plc_did) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
55+ let(:web_did) { 'did:web:taylorswift.com' }
66+77+ describe '#initialize' do
88+ context 'with a valid did:plc' do
99+ it 'should return an initialized DID object' do
1010+ did = subject.new(plc_did)
1111+1212+ did.should be_a(DIDKit::DID)
1313+ did.type.should == :plc
1414+ did.did.should be_a(String)
1515+ did.did.should == plc_did
1616+ did.resolved_by.should be_nil
1717+ end
1818+ end
1919+2020+ context 'with a valid did:web' do
2121+ it 'should return an initialized DID object' do
2222+ did = subject.new(web_did)
2323+2424+ did.should be_a(DIDKit::DID)
2525+ did.type.should == :web
2626+ did.did.should be_a(String)
2727+ did.did.should == web_did
2828+ did.resolved_by.should be_nil
2929+ end
3030+ end
3131+3232+ context 'with another DID object' do
3333+ it 'should create a copy of the DID' do
3434+ other = subject.new(plc_did)
3535+ did = subject.new(other)
3636+3737+ did.did.should == plc_did
3838+ did.type.should == :plc
3939+ did.equal?(other).should == false
4040+ end
4141+ end
4242+4343+ context 'with a string that is not a DID' do
4444+ it 'should raise an error' do
4545+ expect {
4646+ subject.new('not-a-did')
4747+ }.to raise_error(DIDKit::DIDError)
4848+ end
4949+ end
5050+5151+ context 'when an unrecognized did: type' do
5252+ it 'should raise an error' do
5353+ expect {
5454+ subject.new('did:example:123')
5555+ }.to raise_error(DIDKit::DIDError)
5656+ end
5757+ end
5858+ end
5959+6060+ describe '#web_domain' do
6161+ context 'for a did:web' do
6262+ it 'should return the domain part' do
6363+ did = subject.new('did:web:site.example.com')
6464+6565+ did.web_domain.should == 'site.example.com'
6666+ end
6767+ end
6868+6969+ context 'for a did:plc' do
7070+ it 'should return nil' do
7171+ did = subject.new('did:plc:yk4dd2qkboz2yv6tpubpc6co')
7272+7373+ did.web_domain.should be_nil
7474+ end
7575+ end
7676+ end
7777+7878+ describe '#==' do
7979+ let(:did_string) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
8080+ let(:other_string) { 'did:plc:oio4hkxaop4ao4wz2pp3f4cr' }
8181+8282+ let(:did) { subject.new(did_string) }
8383+ let(:other) { subject.new(other_string) }
8484+8585+ context 'given a DID string' do
8686+ it 'should compare its string value to the other DID' do
8787+ did.should == did_string
8888+ did.should_not == other_string
8989+ end
9090+ end
9191+9292+ context 'given another DID object' do
9393+ it "should compare its string value to the other DID's string value" do
9494+ copy = subject.new(did_string)
9595+9696+ did.should == copy
9797+ did.should_not == other
9898+ end
9999+ end
100100+101101+ context 'given something that is not a DID' do
102102+ it 'should return false' do
103103+ did.should_not == :didplc
104104+ did.should_not == [did_string]
105105+ end
106106+ end
107107+ end
108108+109109+ describe '#to_s' do
110110+ it "should return the DID's string value" do
111111+ did = subject.new(plc_did)
112112+113113+ did.to_s.should be_a(String)
114114+ did.to_s.should == plc_did
115115+ end
116116+ end
117117+118118+ describe 'account status' do
119119+ let(:document) { stub(:pds_endpoint => 'https://pds.ruby.space') }
120120+ let(:did) { subject.new(plc_did) }
121121+122122+ before do
123123+ did.stubs(:document).returns(document)
124124+125125+ stub_request(:get, 'https://pds.ruby.space/xrpc/com.atproto.sync.getRepoStatus')
126126+ .with(query: { did: plc_did })
127127+ .to_return(http_response) if defined?(http_response)
128128+ end
129129+130130+ context 'when repo is active' do
131131+ let(:http_response) {
132132+ { body: { active: true }.to_json, headers: { 'Content-Type' => 'application/json' }}
133133+ }
134134+135135+ it 'should report active account state' do
136136+ did.account_status.should == :active
137137+ did.account_active?.should == true
138138+ did.account_exists?.should == true
139139+ end
140140+ end
141141+142142+ context 'when repo is inactive' do
143143+ let(:http_response) {
144144+ { body: { active: false, status: 'takendown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
145145+ }
146146+147147+ it 'should report an inactive existing account' do
148148+ did.account_status.should == :takendown
149149+ did.account_active?.should == false
150150+ did.account_exists?.should == true
151151+ end
152152+ end
153153+154154+ context 'when repo is not found' do
155155+ let(:http_response) {
156156+ { status: 400, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
157157+ }
158158+159159+ it 'should return nil status and report the account as missing' do
160160+ did.account_status.should be_nil
161161+ did.account_active?.should == false
162162+ did.account_exists?.should == false
163163+ end
164164+ end
165165+166166+ context 'when the document has no pds endpoint' do
167167+ before do
168168+ did.stubs(:document).returns(stub(:pds_endpoint => nil))
169169+ end
170170+171171+ it 'should return nil status and report the account as missing' do
172172+ did.account_status.should be_nil
173173+ did.account_active?.should == false
174174+ did.account_exists?.should == false
175175+ end
176176+ end
177177+178178+ context 'when active field is not set' do
179179+ let(:http_response) {
180180+ { body: { active: nil, status: 'unknown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
181181+ }
182182+183183+ it 'should raise APIError' do
184184+ expect { did.account_status }.to raise_error(DIDKit::APIError)
185185+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
186186+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
187187+ end
188188+ end
189189+190190+ context 'when active is false but status is not set' do
191191+ let(:http_response) {
192192+ { body: { active: false, status: nil }.to_json, headers: { 'Content-Type' => 'application/json' }}
193193+ }
194194+195195+ it 'should raise APIError' do
196196+ expect { did.account_status }.to raise_error(DIDKit::APIError)
197197+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
198198+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
199199+ end
200200+ end
201201+202202+ context 'when an error different than RepoNotFound is returned' do
203203+ let(:http_response) {
204204+ { status: 400, body: { error: 'UserIsJerry' }.to_json, headers: { 'Content-Type' => 'application/json' }}
205205+ }
206206+207207+ it 'should raise APIError' do
208208+ expect { did.account_status }.to raise_error(DIDKit::APIError)
209209+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
210210+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
211211+ end
212212+ end
213213+214214+ context 'when the response is not application/json' do
215215+ let(:http_response) {
216216+ { status: 400, body: 'error', headers: { 'Content-Type' => 'text/html' }}
217217+ }
218218+219219+ it 'should raise APIError' do
220220+ expect { did.account_status }.to raise_error(DIDKit::APIError)
221221+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
222222+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
223223+ end
224224+ end
225225+226226+ context 'when the response is not 200 or 400' do
227227+ let(:http_response) {
228228+ { status: 500, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
229229+ }
230230+231231+ it 'should raise APIError' do
232232+ expect { did.account_status }.to raise_error(DIDKit::APIError)
233233+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
234234+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
235235+ end
236236+ end
237237+ end
238238+end
+2-2
spec/didkit_spec.rb
···11# frozen_string_literal: true
2233-RSpec.describe Didkit do
33+RSpec.describe DIDKit do
44 it "has a version number" do
55- expect(Didkit::VERSION).not_to be nil
55+ expect(DIDKit::VERSION).not_to be nil
66 end
77end
···11+describe DIDKit::Document do
22+ subject { described_class }
33+44+ let(:did) { DID.new('did:plc:yk4dd2qkboz2yv6tpubpc6co') }
55+ let(:base_json) { load_did_json('dholms.json') }
66+77+ describe '#initialize' do
88+ context 'with valid input' do
99+ let(:json) { base_json }
1010+1111+ it 'should return a Document object' do
1212+ doc = subject.new(did, json)
1313+1414+ doc.should be_a(DIDKit::Document)
1515+ doc.did.should == did
1616+ doc.json.should == json
1717+ end
1818+1919+ it 'should parse services from the JSON' do
2020+ doc = subject.new(did, json)
2121+2222+ doc.services.should be_an(Array)
2323+ doc.services.length.should == 1
2424+2525+ doc.services[0].should be_a(DIDKit::ServiceRecord)
2626+ doc.services[0].key.should == 'atproto_pds'
2727+ doc.services[0].type.should == 'AtprotoPersonalDataServer'
2828+ doc.services[0].endpoint.should == 'https://pds.dholms.xyz'
2929+ end
3030+3131+ it 'should parse handles from the JSON' do
3232+ doc = subject.new(did, json)
3333+3434+ doc.handles.should == ['dholms.xyz']
3535+ end
3636+ end
3737+3838+ context 'when id is missing' do
3939+ let(:json) { base_json.dup.tap { |h| h.delete('id') }}
4040+4141+ it 'should raise a format error' do
4242+ expect {
4343+ subject.new(did, json)
4444+ }.to raise_error(DIDKit::FormatError)
4545+ end
4646+ end
4747+4848+ context 'when id is not a string' do
4949+ let(:json) { base_json.merge('id' => 123) }
5050+5151+ it 'should raise a format error' do
5252+ expect {
5353+ subject.new(did, json)
5454+ }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when id does not match the DID' do
5959+ let(:json) { base_json.merge('id' => 'did:plc:notmatching') }
6060+6161+ it 'should raise a format error' do
6262+ expect {
6363+ subject.new(did, json)
6464+ }.to raise_error(DIDKit::FormatError)
6565+ end
6666+ end
6767+6868+ context 'when alsoKnownAs is not an array' do
6969+ let(:json) { base_json.merge('alsoKnownAs' => 'at://dholms.xyz') }
7070+7171+ it 'should raise an AtHandles format error' do
7272+ expect {
7373+ subject.new(did, json)
7474+ }.to raise_error(DIDKit::FormatError)
7575+ end
7676+ end
7777+7878+ context 'when alsoKnownAs elements are not strings' do
7979+ let(:json) { base_json.merge('alsoKnownAs' => [666]) }
8080+8181+ it 'should raise an AtHandles format error' do
8282+ expect {
8383+ subject.new(did, json)
8484+ }.to raise_error(DIDKit::FormatError)
8585+ end
8686+ end
8787+8888+ context 'when alsoKnownAs contains multiple handles' do
8989+ let(:json) {
9090+ base_json.merge('alsoKnownAs' => [
9191+ 'at://dholms.xyz',
9292+ 'https://example.com',
9393+ 'at://other.handle'
9494+ ])
9595+ }
9696+9797+ it 'should pick those starting with at:// and remove the prefixes' do
9898+ doc = subject.new(did, json)
9999+ doc.handles.should == ['dholms.xyz', 'other.handle']
100100+ end
101101+ end
102102+103103+ context 'when service is not an array' do
104104+ let(:json) { base_json.merge('service' => 'not-an-array') }
105105+106106+ it 'should raise a format error' do
107107+ expect {
108108+ subject.new(did, json)
109109+ }.to raise_error(DIDKit::FormatError)
110110+ end
111111+ end
112112+113113+ context 'when service entries are not hashes' do
114114+ let(:json) { base_json.merge('service' => ['invalid']) }
115115+116116+ it 'should raise a format error' do
117117+ expect {
118118+ subject.new(did, json)
119119+ }.to raise_error(DIDKit::FormatError)
120120+ end
121121+ end
122122+123123+ context 'when service entries are partially valid' do
124124+ let(:services) {
125125+ [
126126+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
127127+ { 'id' => 'not_a_hash', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
128128+ { 'id' => '#wrong_type', 'type' => 123, 'serviceEndpoint' => 'https://pds.dholms.xyz' },
129129+ { 'id' => '#wrong_endpoint', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 123 },
130130+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
131131+ ]
132132+ }
133133+134134+ let(:json) { base_json.merge('service' => services) }
135135+136136+ it 'should only keep the valid records' do
137137+ doc = subject.new(did, json)
138138+139139+ doc.services.length.should == 2
140140+ doc.services.map(&:key).should == ['atproto_pds', 'lycan']
141141+ doc.services.map(&:type).should == ['AtprotoPersonalDataServer', 'LycanService']
142142+ doc.services.map(&:endpoint).should == ['https://pds.dholms.xyz', 'https://lycan.feeds.blue']
143143+ end
144144+ end
145145+ end
146146+147147+ describe 'service helpers' do
148148+ let(:service_json) {
149149+ base_json.merge('service' => [
150150+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
151151+ { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz' },
152152+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
153153+ ])
154154+ }
155155+156156+ describe '#pds_endpoint' do
157157+ it 'should return the endpoint of #atproto_pds' do
158158+ doc = subject.new(did, service_json)
159159+ doc.pds_endpoint.should == 'https://pds.dholms.xyz'
160160+ end
161161+ end
162162+163163+ describe '#pds_host' do
164164+ it 'should return the host part of #atproto_pds endpoint' do
165165+ doc = subject.new(did, service_json)
166166+ doc.pds_host.should == 'pds.dholms.xyz'
167167+ end
168168+ end
169169+170170+ describe '#labeler_endpoint' do
171171+ it 'should return the endpoint of #atproto_labeler' do
172172+ doc = subject.new(did, service_json)
173173+ doc.labeler_endpoint.should == 'https://labels.dholms.xyz'
174174+ end
175175+ end
176176+177177+ describe '#labeler_host' do
178178+ it 'should return the host part of #atproto_labeler endpoint' do
179179+ doc = subject.new(did, service_json)
180180+ doc.labeler_host.should == 'labels.dholms.xyz'
181181+ end
182182+ end
183183+184184+ describe '#get_service' do
185185+ it 'should fetch a service by key and type' do
186186+ doc = subject.new(did, service_json)
187187+188188+ lycan = doc.get_service('lycan', 'LycanService')
189189+ lycan.should_not be_nil
190190+ lycan.endpoint.should == 'https://lycan.feeds.blue'
191191+ end
192192+193193+ it 'should return nil if none of the services match' do
194194+ doc = subject.new(did, service_json)
195195+196196+ result = doc.get_service('lycan', 'AtprotoLabeler')
197197+ result.should be_nil
198198+199199+ result = doc.get_service('atproto_pds', 'PDS')
200200+ result.should be_nil
201201+202202+ result = doc.get_service('unknown', 'Test')
203203+ result.should be_nil
204204+ end
205205+ end
206206+207207+ it 'should expose the "labeller" aliases for endpoint and host' do
208208+ doc = subject.new(did, service_json)
209209+210210+ doc.labeller_endpoint.should == 'https://labels.dholms.xyz'
211211+ doc.labeller_host.should == 'labels.dholms.xyz'
212212+ end
213213+214214+ describe 'if there is no matching service' do
215215+ let(:service_json) {
216216+ base_json.merge('service' => [
217217+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
218218+ ])
219219+ }
220220+221221+ it 'should return nil from the relevant methods' do
222222+ doc = subject.new(did, service_json)
223223+224224+ doc.pds_endpoint.should be_nil
225225+ doc.pds_host.should be_nil
226226+ doc.labeller_endpoint.should be_nil
227227+ doc.labeller_host.should be_nil
228228+ doc.labeler_endpoint.should be_nil
229229+ doc.labeler_host.should be_nil
230230+ end
231231+ end
232232+ end
233233+end
+358
spec/plc_operation_spec.rb
···11+require 'time'
22+33+describe DIDKit::PLCOperation do
44+ subject { described_class }
55+66+ let(:base_json) { load_did_json('bnewbold_log.json').last }
77+88+ describe '#initialize' do
99+ context 'with a valid plc operation' do
1010+ let(:json) { base_json }
1111+1212+ it 'should return a PLCOperation with parsed data' do
1313+ op = subject.new(json)
1414+1515+ op.json.should == json
1616+ op.type.should == :plc_operation
1717+ op.did.should == 'did:plc:44ybard66vv44zksje25o7dz'
1818+ op.cid.should == 'bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m'
1919+ op.created_at.should be_a(Time)
2020+ op.created_at.should == Time.parse("2025-06-06T00:34:40.824Z")
2121+ op.handles.should == ['bnewbold.net']
2222+ op.services.map(&:key).should == ['atproto_pds']
2323+ end
2424+ end
2525+2626+ context 'when argument is not a hash' do
2727+ let(:json) { [base_json] }
2828+2929+ it 'should raise a format error' do
3030+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3131+ end
3232+ end
3333+3434+ context 'when did is missing' do
3535+ let(:json) { base_json.tap { |h| h.delete('did') }}
3636+3737+ it 'should raise a format error' do
3838+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3939+ end
4040+ end
4141+4242+ context 'when did is not a string' do
4343+ let(:json) { base_json.merge('did' => 123) }
4444+4545+ it 'should raise a format error' do
4646+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
4747+ end
4848+ end
4949+5050+ context "when did doesn't start with did:" do
5151+ let(:json) { base_json.merge('did' => 'foobar') }
5252+5353+ it 'should raise a format error' do
5454+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when cid is missing' do
5959+ let(:json) { base_json.tap { |h| h.delete('cid') }}
6060+6161+ it 'should raise a format error' do
6262+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
6363+ end
6464+ end
6565+6666+ context 'when cid is not a string' do
6767+ let(:json) { base_json.merge('cid' => 700) }
6868+6969+ it 'should raise a format error' do
7070+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7171+ end
7272+ end
7373+7474+ context 'when createdAt is missing' do
7575+ let(:json) { base_json.tap { |h| h.delete('createdAt') }}
7676+7777+ it 'should raise a format error' do
7878+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7979+ end
8080+ end
8181+8282+ context 'when createdAt is invalid' do
8383+ let(:json) { base_json.merge('createdAt' => 123) }
8484+8585+ it 'should raise a format error' do
8686+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
8787+ end
8888+ end
8989+9090+ context 'when operation block is missing' do
9191+ let(:json) { base_json.tap { |h| h.delete('operation') }}
9292+9393+ it 'should raise a format error' do
9494+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
9595+ end
9696+ end
9797+9898+ context 'when operation block is not a hash' do
9999+ let(:json) { base_json.merge('operation' => 'invalid') }
100100+101101+ it 'should raise a format error' do
102102+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
103103+ end
104104+ end
105105+106106+ context 'when operation type is missing' do
107107+ let(:json) { base_json.tap { |h| h['operation'].delete('type') }}
108108+109109+ it 'should raise a format error' do
110110+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
111111+ end
112112+ end
113113+114114+ context 'when operation type is not plc_operation' do
115115+ let(:json) { base_json.tap { |h| h['operation']['type'] = 'other' }}
116116+117117+ it 'should not raise an error' do
118118+ expect { subject.new(json) }.not_to raise_error
119119+ end
120120+121121+ it 'should return the operation type' do
122122+ op = subject.new(json)
123123+ op.type.should == :other
124124+ end
125125+126126+ it 'should not try to parse services' do
127127+ json['services'] = nil
128128+129129+ expect { subject.new(json) }.not_to raise_error
130130+ end
131131+132132+ it 'should return nil from services' do
133133+ op = subject.new(json)
134134+ op.services.should be_nil
135135+ end
136136+137137+ it 'should not try to parse handles' do
138138+ json['alsoKnownAs'] = nil
139139+140140+ expect { subject.new(json) }.not_to raise_error
141141+ end
142142+143143+ it 'should return nil from handles' do
144144+ op = subject.new(json)
145145+ op.handles.should be_nil
146146+ end
147147+ end
148148+149149+ context 'when alsoKnownAs is not an array' do
150150+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = 'at://dholms.xyz' }}
151151+152152+ it 'should raise an AtHandles format error' do
153153+ expect {
154154+ subject.new(json)
155155+ }.to raise_error(DIDKit::FormatError)
156156+ end
157157+ end
158158+159159+ context 'when alsoKnownAs elements are not strings' do
160160+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = [666] }}
161161+162162+ it 'should raise an AtHandles format error' do
163163+ expect {
164164+ subject.new(json)
165165+ }.to raise_error(DIDKit::FormatError)
166166+ end
167167+ end
168168+169169+ context 'when alsoKnownAs contains multiple handles' do
170170+ let(:json) {
171171+ base_json.tap { |h|
172172+ h['operation']['alsoKnownAs'] = [
173173+ 'at://dholms.xyz',
174174+ 'https://example.com',
175175+ 'at://other.handle'
176176+ ]
177177+ }
178178+ }
179179+180180+ it 'should pick those starting with at:// and remove the prefixes' do
181181+ op = subject.new(json)
182182+ op.handles.should == ['dholms.xyz', 'other.handle']
183183+ end
184184+ end
185185+186186+ context 'when services are missing' do
187187+ let(:json) { base_json.tap { |h| h['operation'].delete('services') }}
188188+189189+ it 'should raise a format error' do
190190+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
191191+ end
192192+ end
193193+194194+ context 'when services entry is not a hash' do
195195+ let(:json) {
196196+ base_json.tap { |h|
197197+ h['operation']['services'] = [
198198+ {
199199+ "id": "#atproto_pds",
200200+ "type": "AtprotoPersonalDataServer",
201201+ "serviceEndpoint": "https://pds.dholms.xyz"
202202+ }
203203+ ]
204204+ }
205205+ }
206206+207207+ it 'should raise a format error' do
208208+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
209209+ end
210210+ end
211211+212212+ context 'when a service entry is missing fields' do
213213+ let(:json) {
214214+ base_json.tap { |h|
215215+ h['operation']['services'] = {
216216+ "atproto_pds" => {
217217+ "endpoint" => "https://pds.dholms.xyz"
218218+ },
219219+ "atproto_labeler" => {
220220+ "type" => "AtprotoLabeler",
221221+ "endpoint" => "https://labeler.example.com"
222222+ }
223223+ }
224224+ }
225225+ }
226226+227227+ it 'should raise a format error' do
228228+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
229229+ end
230230+ end
231231+232232+ context 'when services are valid' do
233233+ let(:json) {
234234+ base_json.tap { |h|
235235+ h['operation']['services'] = {
236236+ "atproto_pds" => {
237237+ "type" => "AtprotoPersonalDataServer",
238238+ "endpoint" => "https://pds.dholms.xyz"
239239+ },
240240+ "atproto_labeler" => {
241241+ "type" => "AtprotoLabeler",
242242+ "endpoint" => "https://labeler.example.com"
243243+ },
244244+ "custom_service" => {
245245+ "type" => "OtherService",
246246+ "endpoint" => "https://custom.example.com"
247247+ }
248248+ }
249249+ }
250250+ }
251251+252252+ it 'should parse services into ServiceRecords' do
253253+ op = subject.new(json)
254254+255255+ op.services.length.should == 3
256256+ op.services.each { |s| s.should be_a(DIDKit::ServiceRecord) }
257257+258258+ pds, labeller, custom = op.services
259259+260260+ pds.type.should == 'AtprotoPersonalDataServer'
261261+ pds.endpoint.should == 'https://pds.dholms.xyz'
262262+263263+ labeller.type.should == 'AtprotoLabeler'
264264+ labeller.endpoint.should == 'https://labeler.example.com'
265265+266266+ custom.type.should == 'OtherService'
267267+ custom.endpoint.should == 'https://custom.example.com'
268268+ end
269269+270270+ it 'should allow fetching services by key + type' do
271271+ op = subject.new(json)
272272+273273+ custom = op.get_service('custom_service', 'OtherService')
274274+ custom.should be_a(DIDKit::ServiceRecord)
275275+ custom.endpoint.should == 'https://custom.example.com'
276276+ end
277277+278278+ describe '#pds_endpoint' do
279279+ it 'should return the endpoint of #atproto_pds' do
280280+ op = subject.new(json)
281281+ op.pds_endpoint.should == 'https://pds.dholms.xyz'
282282+ end
283283+ end
284284+285285+ describe '#pds_host' do
286286+ it 'should return the host part of #atproto_pds endpoint' do
287287+ op = subject.new(json)
288288+ op.pds_host.should == 'pds.dholms.xyz'
289289+ end
290290+ end
291291+292292+ describe '#labeler_endpoint' do
293293+ it 'should return the endpoint of #atproto_labeler' do
294294+ op = subject.new(json)
295295+ op.labeler_endpoint.should == 'https://labeler.example.com'
296296+ end
297297+ end
298298+299299+ describe '#labeler_host' do
300300+ it 'should return the host part of #atproto_labeler endpoint' do
301301+ op = subject.new(json)
302302+ op.labeler_host.should == 'labeler.example.com'
303303+ end
304304+ end
305305+306306+ it 'should expose the "labeller" aliases for endpoint and host' do
307307+ op = subject.new(json)
308308+309309+ op.labeller_endpoint.should == 'https://labeler.example.com'
310310+ op.labeller_host.should == 'labeler.example.com'
311311+ end
312312+ end
313313+314314+ context 'when services are valid but the specific ones are missing' do
315315+ let(:json) {
316316+ base_json.tap { |h|
317317+ h['operation']['services'] = {
318318+ "custom_service" => {
319319+ "type" => "CustomService",
320320+ "endpoint" => "https://custom.example.com"
321321+ }
322322+ }
323323+ }
324324+ }
325325+326326+ it 'should parse service records' do
327327+ op = subject.new(json)
328328+ op.services.length.should == 1
329329+ end
330330+331331+ describe '#get_service' do
332332+ it 'should return nil' do
333333+ op = subject.new(json)
334334+ other = op.get_service('other_service', 'OtherService')
335335+ other.should be_nil
336336+ end
337337+ end
338338+339339+ describe '#pds_endpoint' do
340340+ it 'should return nil' do
341341+ op = subject.new(json)
342342+ op.pds_endpoint.should be_nil
343343+ op.pds_host.should be_nil
344344+ end
345345+ end
346346+347347+ describe '#labeler_endpoint' do
348348+ it 'should return nil' do
349349+ op = subject.new(json)
350350+ op.labeler_endpoint.should be_nil
351351+ op.labeller_endpoint.should be_nil
352352+ op.labeler_host.should be_nil
353353+ op.labeller_host.should be_nil
354354+ end
355355+ end
356356+ end
357357+ end
358358+end
+179
spec/resolver_spec.rb
···11+describe DIDKit::Resolver do
22+ let(:sample_did) { 'did:plc:qhfo22pezo44fa3243z2h4ny' }
33+44+ describe '#resolve_handle' do
55+ context 'when handle resolves via HTTP' do
66+ before do
77+ Resolv::DNS.stubs(:open).returns([])
88+ end
99+1010+ let(:handle) { 'barackobama.bsky.social' }
1111+1212+ it 'should return a matching DID' do
1313+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
1414+ .to_return(body: sample_did)
1515+1616+ result = subject.resolve_handle(handle)
1717+1818+ result.should_not be_nil
1919+ result.should be_a(DID)
2020+ result.to_s.should == sample_did
2121+ result.resolved_by.should == :http
2222+ end
2323+2424+ it 'should check DNS first' do
2525+ Resolv::DNS.expects(:open).returns([])
2626+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
2727+ .to_return(body: sample_did)
2828+2929+ result = subject.resolve_handle(handle)
3030+ end
3131+3232+ context 'when HTTP returns invalid text' do
3333+ it 'should return nil' do
3434+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
3535+ .to_return(body: "Welcome to nginx!")
3636+3737+ result = subject.resolve_handle(handle)
3838+ result.should be_nil
3939+ end
4040+ end
4141+4242+ context 'when HTTP returns bad response' do
4343+ it 'should return nil' do
4444+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
4545+ .to_return(status: 400, body: sample_did)
4646+4747+ result = subject.resolve_handle(handle)
4848+ result.should be_nil
4949+ end
5050+ end
5151+5252+ context 'when HTTP throws an exception' do
5353+ it 'should catch it and return nil' do
5454+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
5555+ .to_raise(Errno::ETIMEDOUT)
5656+5757+ result = 0
5858+5959+ expect {
6060+ result = subject.resolve_handle(handle)
6161+ }.to_not raise_error
6262+6363+ result.should be_nil
6464+ end
6565+ end
6666+6767+ context 'when HTTP response has a trailing newline' do
6868+ it 'should accept it' do
6969+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
7070+ .to_return(body: sample_did + "\n")
7171+7272+ result = subject.resolve_handle(handle)
7373+7474+ result.should_not be_nil
7575+ result.should be_a(DID)
7676+ result.to_s.should == sample_did
7777+ end
7878+ end
7979+ end
8080+8181+ context 'when handle has a leading @' do
8282+ let(:handle) { '@pfrazee.com' }
8383+8484+ before do
8585+ Resolv::DNS.stubs(:open).returns([])
8686+ end
8787+8888+ it 'should also return a matching DID' do
8989+ stub_request(:get, "https://pfrazee.com/.well-known/atproto-did")
9090+ .to_return(body: sample_did)
9191+9292+ result = subject.resolve_handle(handle)
9393+9494+ result.should_not be_nil
9595+ result.should be_a(DID)
9696+ result.to_s.should == sample_did
9797+ result.resolved_by.should == :http
9898+ end
9999+ end
100100+101101+ context 'when handle has a reserved TLD' do
102102+ let(:handle) { 'example.test' }
103103+104104+ it 'should return nil' do
105105+ subject.resolve_handle(handle).should be_nil
106106+ end
107107+ end
108108+109109+ context 'when a DID string is passed' do
110110+ let(:handle) { BSKY_APP_DID }
111111+112112+ it 'should return that DID' do
113113+ result = subject.resolve_handle(handle)
114114+115115+ result.should be_a(DID)
116116+ result.to_s.should == BSKY_APP_DID
117117+ end
118118+ end
119119+120120+ context 'when a DID object is passed' do
121121+ let(:handle) { DID.new(BSKY_APP_DID) }
122122+123123+ it 'should return a new DID object with that DID' do
124124+ result = subject.resolve_handle(handle)
125125+126126+ result.should be_a(DID)
127127+ result.to_s.should == BSKY_APP_DID
128128+ result.equal?(handle).should == false
129129+ end
130130+ end
131131+ end
132132+133133+ describe '#resolve_did' do
134134+ context 'when passed a did:plc string' do
135135+ let(:did) { 'did:plc:yk4dd2qkboz2yv6tpubpc6co' }
136136+137137+ it 'should return a parsed DID document object' do
138138+ stub_request(:get, "https://plc.directory/#{did}")
139139+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
140140+141141+ result = subject.resolve_did(did)
142142+ result.should be_a(DIDKit::Document)
143143+ result.handles.should == ['dholms.xyz']
144144+ result.pds_endpoint.should == 'https://pds.dholms.xyz'
145145+ end
146146+147147+ it 'should require a valid content type' do
148148+ stub_request(:get, "https://plc.directory/#{did}")
149149+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'text/plain' })
150150+151151+ expect { subject.resolve_did(did) }.to raise_error(DIDKit::APIError)
152152+ end
153153+ end
154154+155155+ context 'when passed a did:web string' do
156156+ let(:did) { 'did:web:witchcraft.systems' }
157157+158158+ it 'should return a parsed DID document object' do
159159+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
160160+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
161161+162162+ result = subject.resolve_did(did)
163163+ result.should be_a(DIDKit::Document)
164164+ result.handles.should == ['witchcraft.systems']
165165+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
166166+ end
167167+168168+ it 'should NOT require a valid content type' do
169169+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
170170+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'text/plain' })
171171+172172+ result = subject.resolve_did(did)
173173+ result.should be_a(DIDKit::Document)
174174+ result.handles.should == ['witchcraft.systems']
175175+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
176176+ end
177177+ end
178178+ end
179179+end
+44-5
spec/spec_helper.rb
···11# frozen_string_literal: true
2233-require "didkit"
33+require 'simplecov'
44+55+SimpleCov.start do
66+ enable_coverage :branch
77+ add_filter "/spec/"
88+end
99+1010+require 'didkit'
1111+require 'json'
1212+require 'webmock/rspec'
413514RSpec.configure do |config|
615 # Enable flags like --only-failures and --next-failure
716 config.example_status_persistence_file_path = ".rspec_status"
81799- # Disable RSpec exposing methods globally on `Module` and `main`
1010- config.disable_monkey_patching!
1818+ config.expect_with :rspec do |c|
1919+ c.syntax = [:should, :expect]
2020+ end
2121+2222+ config.mock_with :mocha
2323+end
2424+2525+module SimpleCov
2626+ module Formatter
2727+ class HTMLFormatter
2828+ def format(result)
2929+ # silence the stdout summary, just save the html files
3030+ unless @inline_assets
3131+ Dir[File.join(@public_assets_dir, "*")].each do |path|
3232+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
3333+ end
3434+ end
11351212- config.expect_with :rspec do |c|
1313- c.syntax = :expect
3636+ File.open(File.join(output_path, "index.html"), "wb") do |file|
3737+ file.puts template("layout").result(binding)
3838+ end
3939+ end
4040+ end
1441 end
1542end
4343+4444+BSKY_APP_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur'
4545+4646+WebMock.enable!
4747+4848+def load_did_file(name)
4949+ File.read(File.join(__dir__, 'dids', name))
5050+end
5151+5252+def load_did_json(name)
5353+ JSON.parse(load_did_file(name))
5454+end