A library for handling DID identifiers used in Bluesky AT Protocol

Compare changes

Choose any two refs to compare.

+2071 -148
+4
.github/workflows/main.yml
··· 14 14 strategy: 15 15 matrix: 16 16 ruby: 17 + - '2.6' 17 18 - '2.7' 18 19 - '3.0' 19 20 - '3.1' 20 21 - '3.2' 22 + - '3.3' 23 + - '3.4' 24 + - '4.0' 21 25 22 26 steps: 23 27 - uses: actions/checkout@v3
+4
.gitignore
··· 1 1 .bundle 2 + .DS_Store 2 3 .rspec_status 4 + .yardoc 5 + coverage 6 + doc 3 7 Gemfile.lock
+4
.yardopts
··· 1 + --protected 2 + --no-private 3 + --markup markdown 4 + --embed-mixin "DIDKit::Services"
+42
CHANGELOG.md
··· 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 + 1 43 ## [0.2.1] - 2024-03-26 2 44 3 45 - tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
+10 -2
Gemfile
··· 5 5 # Specify your gem's dependencies in didkit.gemspec 6 6 gemspec 7 7 8 - gem "rake", "~> 13.0" 9 - gem "rspec", "~> 3.0" 8 + gem 'rake', '~> 13.0' 9 + gem 'rspec', '~> 3.0' 10 + gem 'irb' 11 + 12 + gem 'rdoc' 13 + gem 'yard' 14 + 15 + gem 'mocha' 16 + gem 'simplecov' 17 + gem 'webmock'
+1 -1
LICENSE.txt
··· 1 1 The zlib License 2 2 3 - Copyright (c) 2023 Jakub Suder 3 + Copyright (c) 2026 Jakub Suder 4 4 5 5 This software is provided 'as-is', without any express or implied 6 6 warranty. In no event will the authors be held liable for any damages
+67 -36
README.md
··· 1 - # DIDKit 1 + # DIDKit ๐Ÿชช 2 + 3 + A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol. 2 4 3 - A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol 5 + > [!NOTE] 6 + > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue) 4 7 5 8 6 9 ## What does it do ··· 10 13 11 14 ## Installation 12 15 13 - gem install didkit 16 + To use DIDKit, you need a reasonably new version of Ruby โ€“ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)). 14 17 18 + To install the gem, run in the command line: 15 19 16 - ## Usage 20 + [sudo] gem install didkit 17 21 18 - Use the `DIDKit::Resolver` class to look up DIDs and handles. 22 + Or add this to your app's `Gemfile`: 19 23 20 - To look up a handle: 24 + gem 'didkit', '~> 0.3' 25 + 26 + 27 + ## Usage 28 + 29 + The simplest way to use the gem is through the `DIDKit::DID` class, aliased as just `DID`: 21 30 22 31 ```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> 32 + did = DID.resolve_handle('jay.bsky.team') 33 + # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo", 34 + # @resolved_by=:dns, @type=:plc> 26 35 ``` 27 36 28 - This returns an object of `DIDKit::DID` class (aliased as just `DID`), which tells you: 37 + This returns a `DID` object, which tells you: 29 38 30 39 - the DID as a string (`#to_s` or `#did`) 31 40 - the DID type (`#type`, `:plc` or `:web`) 32 41 - if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`) 33 42 34 - 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): 43 + To go in the other direction โ€“ to find an assigned and verified handle given a DID โ€“ create a `DID` from a DID string and call `get_verified_handle`: 35 44 36 45 ```rb 37 - resolver.get_validated_handle('did:plc:ewvi7nxzyoun6zhxrhs64oiz') 38 - # => "atproto.com" 46 + DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle 47 + # => "atproto.com" 39 48 ``` 40 49 41 - You can also load the DID document using `resolve_did`: 50 + You can also load the DID JSON document using `#document`, which returns a `DIDKit::Document` (`DID` caches the document, so don't worry about calling this method multiple times): 42 51 43 52 ```rb 44 - doc = resolver.resolve_did('did:plc:ragtjsm2j2vknwkz3zp4oxrd') 45 - # => #<DIDKit::Document:0x0000000105d751f8 @did=#<DIDKit::DID:...>, @json={...}> 53 + did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd') 46 54 47 - doc.handles 48 - # => ["pfrazee.com"] 55 + did.document.handles 56 + # => ["pfrazee.com"] 49 57 50 - doc.pds_endpoint 51 - # => "https://morel.us-east.host.bsky.network" 58 + did.document.pds_host 59 + # => "morel.us-east.host.bsky.network" 52 60 ``` 53 61 54 - There are also some helper methods in the `DID` class that create a `Resolver` for you to save you some typing: 62 + 63 + ### Checking account status 64 + 65 + `DIDKit::DID` also includes a few methods for checking the status of a given account (repo), which call the `com.atproto.sync.getRepoStatus` endpoint on the account's assigned PDS: 55 66 56 67 ```rb 57 - did = DID.resolve_handle('jay.bsky.team') 58 - # => #<DIDKit::DID:0x000000010615ed28 @did="did:plc:oky5czdrnfjpqslsw2a5iclo", @type=:plc, @resolved_by=:dns> 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 59 75 60 - did.to_s 61 - # => "did:plc:oky5czdrnfjpqslsw2a5iclo" 76 + did = DID.new('did:plc:44ybard66vv44zksje25o7dz') 77 + did.account_status 78 + # => :active 79 + did.account_active? 80 + # => true 81 + ``` 62 82 63 - did.get_document 64 - # => #<DIDKit::Document:0x00000001066d4898 @did=#<DIDKit::DID:...>, @json={...}> 83 + ### Configuration 84 + 85 + You can customize some things about the DID/handle lookups by using the `DIDKit::Resolver` class, which the methods in `DID` use behind the scenes. 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) 65 92 66 - did.get_validated_handle 67 - # => "jay.bsky.team" 68 - ``` 93 + Example: 69 94 95 + ```rb 96 + resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8', timeout: 30) 70 97 71 - ### Configuration 98 + did = resolver.resolve_handle('nytimes.com') 99 + # => #<DIDKit::DID:0x0... @did="did:plc:eclio37ymobqex2ncko63h4r", 100 + # @resolved_by=:dns, @type=:plc> 72 101 73 - 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: 102 + resolver.resolve_did(did) 103 + # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}> 74 104 75 - ``` 76 - resolver.nameserver = '8.8.8.8' 105 + resolver.get_verified_handle(did) 106 + # => 'nytimes.com' 77 107 ``` 78 - 79 108 80 109 ## Credits 81 110 82 - Copyright ยฉ 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 111 + Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 83 112 84 113 The 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 ๐Ÿ˜Ž
+6 -5
bin/resolve
··· 3 3 4 4 $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) 5 5 6 - require "bundler/setup" 7 - require "didkit" 6 + require 'bundler/setup' 7 + require 'didkit' 8 + require 'json' 8 9 9 10 resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8') 10 11 11 12 begin 12 13 query = ARGV[0].to_s 13 14 14 - if query =~ /^did\:/ 15 + if query =~ DID::GENERIC_REGEXP 15 16 did = DID.new(query) 16 17 elsif query =~ /^@[\w\-]+(\.[\w\-]+)+$/ 17 18 did = resolver.resolve_handle(query) ··· 26 27 end 27 28 28 29 doc = did.get_document 29 - valid_handle = resolver.get_validated_handle(doc) 30 + verified_handle = resolver.get_verified_handle(doc) 30 31 31 32 puts 32 33 puts "PDS: #{doc.pds_endpoint}" 33 34 puts "Resolved by: #{did.resolved_by}" if did.resolved_by 34 - puts "Validated handle: " + (valid_handle ? "@#{valid_handle}" : "โš ๏ธ invalid handle #{doc.handles.inspect}") 35 + puts "Verified handle: " + (verified_handle ? "@#{verified_handle}" : "โš ๏ธ invalid handle #{doc.handles.inspect}") 35 36 puts 36 37 puts JSON.pretty_generate(doc.json) 37 38 rescue StandardError => e
+4 -11
didkit.gemspec
··· 10 10 11 11 spec.summary = "A library for handling Distributed ID (DID) identifiers used in Bluesky AT Protocol" 12 12 # spec.description = "Write a longer description or delete this line." 13 - spec.homepage = "https://github.com/mackuba/didkit" 13 + spec.homepage = "https://ruby.sdk.blue" 14 14 15 15 spec.license = "Zlib" 16 16 spec.required_ruby_version = ">= 2.6.0" 17 17 18 18 spec.metadata = { 19 - "bug_tracker_uri" => "https://github.com/mackuba/didkit/issues", 20 - "changelog_uri" => "https://github.com/mackuba/didkit/blob/master/CHANGELOG.md", 21 - "source_code_uri" => "https://github.com/mackuba/didkit", 19 + "bug_tracker_uri" => "https://tangled.org/mackuba.eu/didkit/issues", 20 + "changelog_uri" => "https://tangled.org/mackuba.eu/didkit/blob/master/CHANGELOG.md", 21 + "source_code_uri" => "https://tangled.org/mackuba.eu/didkit", 22 22 } 23 23 24 24 spec.files = Dir.chdir(__dir__) do ··· 26 26 end 27 27 28 28 spec.require_paths = ["lib"] 29 - 30 - spec.add_dependency 'json', '~> 2.5' 31 - spec.add_dependency 'net-http', '~> 0.1' 32 - spec.add_dependency 'open-uri', '~> 0.1' 33 - spec.add_dependency 'resolv', '~> 0.1' 34 - spec.add_dependency 'time', '~> 0.3' 35 - spec.add_dependency 'uri', '~> 0.13' 36 29 end
+9 -2
lib/didkit/at_handles.rb
··· 1 + require_relative 'errors' 2 + 1 3 module DIDKit 4 + 5 + # 6 + # @private 7 + # 8 + 2 9 module AtHandles 3 - class FormatError < StandardError 4 - end 10 + 11 + private 5 12 6 13 def parse_also_known_as(aka) 7 14 raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.is_a?(Array)
+152 -6
lib/didkit/did.rb
··· 1 + require 'json' 2 + require 'uri' 3 + 1 4 require_relative 'errors' 5 + require_relative 'requests' 2 6 require_relative 'resolver' 3 7 4 8 module DIDKit 9 + 10 + # 11 + # Represents a DID identifier (account on the ATProto network). This class serves as an entry 12 + # point to various lookup helpers. For convenience it can also be accessed as just `DID` without 13 + # the `DIDKit::` prefix. 14 + # 15 + # @example Resolving a handle 16 + # did = DID.resolve_handle('bsky.app') 17 + # 18 + 5 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 + 6 34 def self.resolve_handle(handle) 7 35 Resolver.new.resolve_handle(handle) 8 36 end 9 37 10 - attr_reader :type, :did, :resolved_by 38 + # @return [Symbol] DID type (`:plc` or `:web`) 39 + attr_reader :type 40 + 41 + # @return [String] DID identifier string 42 + attr_reader :did 43 + 44 + # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods 45 + attr_reader :resolved_by 46 + 47 + alias to_s did 48 + 49 + 50 + # Create a DID object from a DID string. 51 + # 52 + # @param did [String, DID] DID string or another DID object 53 + # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`) 54 + # @raise [DIDError] when the DID format or type is invalid 11 55 12 56 def initialize(did, resolved_by = nil) 13 - if did =~ /^did\:(\w+)\:/ 57 + if did.is_a?(DID) 58 + did = did.to_s 59 + end 60 + 61 + if did =~ GENERIC_REGEXP 14 62 @did = did 15 - @type = $1.to_sym 63 + @type = did.split(':')[1].to_sym 16 64 else 17 65 raise DIDError.new("Invalid DID format") 18 66 end ··· 24 72 @resolved_by = resolved_by 25 73 end 26 74 27 - alias to_s did 75 + # Returns or looks up the DID document with the DID's identity details from an appropriate source. 76 + # This method caches the document in a local variable if it's called again. 77 + # 78 + # @return [Document] resolved DID document 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 28 86 29 87 def get_document 30 88 Resolver.new.resolve_did(self) 31 89 end 32 90 33 - def get_validated_handle 34 - Resolver.new.get_validated_handle(self) 91 + # Returns the first verified handle assigned to this DID. 92 + # 93 + # Looks up the domain handles assigned to this DID in its DID document, checks if they are 94 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 95 + # the first handle that validates correctly, or nil if none matches. 96 + # 97 + # @return [String, nil] verified handle domain, if found 98 + 99 + def get_verified_handle 100 + Resolver.new.get_verified_handle(document) 35 101 end 36 102 103 + # Fetches the PLC audit log (list of all previous operations) for a did:plc DID. 104 + # 105 + # @return [Array<PLCOperation>] list of PLC operations in the audit log 106 + # @raise [DIDError] when the DID is not a did:plc 107 + 108 + def get_audit_log 109 + if @type == :plc 110 + PLCImporter.new.fetch_audit_log(self) 111 + else 112 + raise DIDError.new("Audit log not supported for did:#{@type}") 113 + end 114 + end 115 + 116 + # Returns the domain portion of a did:web identifier. 117 + # 118 + # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc 119 + 37 120 def web_domain 38 121 did.gsub(/^did\:web\:/, '') if type == :web 39 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 40 186 41 187 def ==(other) 42 188 if other.is_a?(String)
+59 -18
lib/didkit/document.rb
··· 1 1 require_relative 'at_handles' 2 + require_relative 'errors' 2 3 require_relative 'resolver' 3 4 require_relative 'service_record' 4 5 require_relative 'services' 5 6 6 7 module DIDKit 7 - class Document 8 - class FormatError < StandardError 9 - end 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 + # 10 14 15 + class Document 11 16 include AtHandles 12 17 include Services 13 18 14 - attr_reader :json, :did, :handles, :services 19 + # @return [Hash] the complete JSON data of the DID document 20 + attr_reader :json 21 + 22 + # @return [DID] the DID that this document describes 23 + attr_reader :did 24 + 25 + # Returns a list of handles assigned to this DID in its DID document. 26 + # 27 + # Note: the handles aren't guaranteed to be verified (validated in the other direction). 28 + # Use {#get_verified_handle} to find a handle that is correctly verified. 29 + # 30 + # @return [Array<String>] 31 + attr_reader :handles 32 + 33 + # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID 34 + attr_reader :services 35 + 36 + # Creates a DID document object. 37 + # 38 + # @param did [DID] DID object 39 + # @param json [Hash] DID document JSON 40 + # @raise [FormatError] when required fields are missing or invalid. 15 41 16 42 def initialize(did, json) 17 43 raise FormatError, "Missing id field" if json['id'].nil? ··· 21 47 @did = did 22 48 @json = json 23 49 24 - if service = json['service'] 25 - raise FormatError, "Invalid service data" unless service.is_a?(Array) && service.all? { |x| x.is_a?(Hash) } 50 + @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 26 61 27 - @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) 62 + def get_verified_handle 63 + Resolver.new.get_verified_handle(self) 64 + end 30 65 31 - ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint) 32 - } 33 - else 34 - @services = [] 35 - end 36 66 37 - @handles = parse_also_known_as(json['alsoKnownAs'] || []) 38 - end 67 + 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 39 81 40 - def get_validated_handle 41 - Resolver.new.pick_valid_handle(did, handles) 82 + services 42 83 end 43 84 end 44 85 end
+35
lib/didkit/errors.rb
··· 1 1 module 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 + # 2 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 3 38 end 4 39 end
+38 -8
lib/didkit/plc_importer.rb
··· 1 1 require 'json' 2 - require 'open-uri' 3 2 require 'time' 3 + require 'uri' 4 4 5 5 require_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 + # 6 12 7 13 module DIDKit 8 14 class PLCImporter 9 15 PLC_SERVICE = 'plc.directory' 10 16 MAX_PAGE = 1000 11 17 12 - attr_accessor :ignore_errors, :last_date 18 + include Requests 19 + 20 + attr_accessor :ignore_errors, :last_date, :error_handler 13 21 14 22 def initialize(since: nil) 15 23 if since.to_s == 'beginning' ··· 23 31 @eof = true 24 32 end 25 33 26 - @ignore_errors = false 34 + @last_page_cids = [] 27 35 end 28 36 29 37 def plc_service 30 38 PLC_SERVICE 31 39 end 32 40 41 + 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 + 33 51 def get_export(args = {}) 34 52 url = URI("https://#{plc_service}/export") 35 53 url.query = URI.encode_www_form(args) 36 54 37 - data = URI.open(url).read 55 + data = get_data(url, content_type: 'application/jsonlines') 38 56 data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) } 39 57 end 40 58 59 + 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 + 41 64 def fetch_page 42 65 request_time = Time.now 43 66 44 67 query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {} 45 68 rows = get_export(query) 46 69 47 - operations = rows.filter_map do |json| 70 + operations = rows.filter_map { |json| 48 71 begin 49 72 PLCOperation.new(json) 50 - rescue PLCOperation::FormatError => e 51 - ignore_errors ? nil : raise 73 + rescue PLCOperation::FormatError, AtHandles::FormatError, ServiceRecord::FormatError => e 74 + @error_handler ? @error_handler.call(e, json) : raise 75 + nil 52 76 end 53 - 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 + } 54 83 55 84 @last_date = operations.last&.created_at || request_time 85 + @last_page_cids = Set.new(operations.map(&:cid)) 56 86 @eof = (rows.length < MAX_PAGE) 57 87 58 88 operations
+52 -5
lib/didkit/plc_operation.rb
··· 1 1 require 'time' 2 2 3 3 require_relative 'at_handles' 4 + require_relative 'errors' 4 5 require_relative 'service_record' 5 6 require_relative 'services' 6 7 7 8 module DIDKit 9 + 10 + # 11 + # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory) 12 + # (e.g. changing assigned handles or migrating to a different PDS). 13 + # 14 + 8 15 class PLCOperation 9 - class FormatError < StandardError 10 - end 11 - 12 16 include AtHandles 13 17 include Services 14 18 15 - attr_reader :json, :did, :created_at, :type, :handles, :services 19 + # @return [Hash] the JSON from which the operation is parsed 20 + attr_reader :json 21 + 22 + # @return [String] the DID which the operation concerns 23 + attr_reader :did 24 + 25 + # @return [String] CID (Content Identifier) of the operation 26 + attr_reader :cid 27 + 28 + # Returns a sequential number of the operation (only used in the new export API). 29 + # @return [Integer, nil] sequential number of the operation 30 + attr_reader :seq 31 + 32 + # @return [Time] time when the operation was created 33 + attr_reader :created_at 34 + 35 + # Returns the `type` field of the operation (usually `"plc_operation"`). 36 + # @return [String] the operation type 37 + attr_reader :type 38 + 39 + # Returns a list of handles assigned to the DID in this operation. 40 + # 41 + # Note: the handles aren't guaranteed to be verified (validated in the other direction). 42 + # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is 43 + # correctly verified. 44 + # 45 + # @return [Array<String>] 46 + attr_reader :handles 47 + 48 + # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID 49 + attr_reader :services 50 + 51 + 52 + # Creates a PLCOperation object. 53 + # 54 + # @param json [Hash] operation JSON 55 + # @raise [FormatError] when required fields are missing or invalid 16 56 17 57 def initialize(json) 18 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'] 19 62 @did = json['did'] 20 63 raise FormatError, "Missing DID: #{json}" if @did.nil? 21 - raise FormatError, "Invalid DID: #{@did}" unless @did.is_a?(String) && @did.start_with?('did:') 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) 22 69 23 70 timestamp = json['createdAt'] 24 71 raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
+94
lib/didkit/requests.rb
··· 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' 3 1 require 'net/http' 4 2 require 'resolv' 5 3 6 4 require_relative 'did' 7 5 require_relative 'document' 6 + require_relative 'requests' 8 7 9 8 module DIDKit 9 + 10 + # 11 + # A class which manages resolving of handles to DIDs and DIDs to DID documents. 12 + # 13 + 10 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. 11 17 RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test) 12 - MAX_REDIRECTS = 5 18 + 19 + include Requests 13 20 21 + # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups 14 22 attr_accessor :nameserver 15 23 24 + # @param options [Hash] resolver options 25 + # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs) 26 + # @option options [Integer] :timeout request timeout in seconds (default: 15) 27 + # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5) 28 + 16 29 def initialize(options = {}) 17 30 @nameserver = options[:nameserver] 31 + @request_options = options.slice(:timeout, :max_redirects) 18 32 end 19 33 34 + # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method 35 + # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods. 36 + # 37 + # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this 38 + # method to pass it an input string from the user which can be a DID or handle, without having to 39 + # check which one it is. 40 + # 41 + # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string 42 + # @return [DID, nil] resolved DID if found, nil otherwise 43 + 20 44 def resolve_handle(handle) 45 + if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP 46 + return DID.new(handle) 47 + end 48 + 21 49 domain = handle.gsub(/^@/, '') 22 50 23 51 return nil if RESERVED_DOMAINS.include?(domain.split('.').last) ··· 31 59 end 32 60 end 33 61 62 + # Tries to resolve a handle into DID using the DNS TXT method. 63 + # 64 + # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is 65 + # a correct DID string. 66 + # 67 + # @param domain [String] a domain handle to look up 68 + # @return [String, nil] resolved DID if found, nil otherwise 69 + 34 70 def resolve_handle_by_dns(domain) 35 - dns_records = Resolv::DNS.open(resolv_options) { |d| 71 + dns_records = Resolv::DNS.open(resolv_options) do |d| 36 72 d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) 37 - } 73 + end 38 74 39 75 if record = dns_records.first 40 76 if string = record.strings.first ··· 45 81 nil 46 82 end 47 83 48 - 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) 84 + # Tries to resolve a handle into DID using the HTTP .well-known method. 85 + # 86 + # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns 87 + # a text file that contains a correct DID string. 88 + # 89 + # @param domain [String] a domain handle to look up 90 + # @return [String, nil] resolved DID if found, nil otherwise 54 91 55 - 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 92 + def resolve_handle_by_well_known(domain) 93 + url = "https://#{domain}/.well-known/atproto-did" 94 + response = get_response(url, @request_options) 59 95 60 - 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 96 + if response.is_a?(Net::HTTPSuccess) && (text = response.body) 97 + return parse_did_from_well_known(text) 69 98 end 70 99 71 100 nil ··· 73 102 nil 74 103 end 75 104 105 + # Resolve a DID to a DID document. 106 + # 107 + # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either 108 + # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs. 109 + # 110 + # @param did [String, DID] DID string or object 111 + # @return [Document] resolved DID document 112 + # @raise [APIError] if an incorrect response is returned 113 + 114 + def resolve_did(did) 115 + did = DID.new(did) if did.is_a?(String) 116 + 117 + did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did) 118 + end 119 + 120 + # Returns the first verified handle assigned to the given DID. 121 + # 122 + # Looks up the domain handles assigned to the DID in the DID document, checks if they are 123 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 124 + # the first handle that validates correctly, or nil if none matches. 125 + # 126 + # @param subject [String, DID, Document] a DID or its DID document 127 + # @return [String, nil] verified handle domain, if found 128 + 129 + def get_verified_handle(subject) 130 + document = subject.is_a?(Document) ? subject : resolve_did(subject) 131 + 132 + first_verified_handle(document.did, document.handles) 133 + end 134 + 135 + # Returns the first handle from the list that resolves back to the given DID. 136 + # 137 + # @param did [DID, String] DID to verify the handles against 138 + # @param handles [Array<String>] handles to check 139 + # @return [String, nil] a verified handle, if found 140 + 141 + def first_verified_handle(did, handles) 142 + handles.detect { |h| resolve_handle(h) == did.to_s } 143 + end 144 + 145 + 146 + private 147 + 76 148 def resolv_options 77 149 options = Resolv::DNS::Config.default_config_hash.dup 78 150 options[:nameserver] = nameserver if nameserver ··· 85 157 86 158 def parse_did_from_well_known(text) 87 159 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) 160 + text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil 95 161 end 96 162 97 163 def resolve_did_plc(did) 98 - url = "https://plc.directory/#{did}" 99 - json = JSON.parse(URI.open(url).read) 164 + json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/) 100 165 Document.new(did, json) 101 166 end 102 167 103 168 def resolve_did_web(did) 104 - url = "https://#{did.web_domain}/.well-known/did.json" 105 - json = JSON.parse(URI.open(url).read) 169 + json = get_json("https://#{did.web_domain}/.well-known/did.json") 106 170 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 171 end 118 172 end 119 173 end
+22 -1
lib/didkit/service_record.rb
··· 2 2 require_relative 'errors' 3 3 4 4 module DIDKit 5 + 6 + # A parsed service record from either a DID document's `service` field or a PLC directory 7 + # operation's `services` field. 8 + 5 9 class ServiceRecord 6 - attr_reader :key, :type, :endpoint 10 + 11 + # Returns the service's identifier (without `#`), like "atproto_pds". 12 + # @return [String] service's identifier 13 + attr_reader :key 14 + 15 + # Returns the service's type field, like "AtprotoPersonalDataServer". 16 + # @return [String] service's type 17 + attr_reader :type 18 + 19 + # @return [String] service's endpoint URL 20 + attr_reader :endpoint 21 + 22 + # Create a service record from DID document fields. 23 + # 24 + # @param key [String] service identifier (without `#`) 25 + # @param type [String] service type 26 + # @param endpoint [String] service endpoint URL 27 + # @raise [FormatError] when the endpoint is not a valid URI 7 28 8 29 def initialize(key, type, endpoint) 9 30 begin
+53
lib/didkit/services.rb
··· 1 + require 'uri' 2 + 1 3 module DIDKit 4 + 5 + # 6 + # @api private 7 + # 8 + 2 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 + 3 18 def get_service(key, type) 4 19 @services&.detect { |s| s.key == key && s.type == type } 5 20 end 6 21 22 + # Returns the PDS service endpoint, if present. 23 + # 24 + # If the DID has an `#atproto_pds` service declared in its `service` section, 25 + # returns the URL in its `serviceEndpoint` field. In other words, this is the URL 26 + # of the PDS assigned to a given user, which stores the user's account and repo. 27 + # 28 + # @api public 29 + # @return [String, nil] PDS service endpoint URL 30 + 7 31 def pds_endpoint 8 32 @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint 9 33 end 10 34 35 + # Returns the labeler service endpoint, if present. 36 + # 37 + # If the DID has an `#atproto_labeler` service declared in its `service` section, 38 + # returns the URL in its `serviceEndpoint` field. 39 + # 40 + # @api public 41 + # @return [String, nil] labeler service endpoint URL 42 + 11 43 def labeler_endpoint 12 44 @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint 13 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 14 67 end 15 68 end
+1 -1
lib/didkit/version.rb
··· 1 1 # frozen_string_literal: true 2 2 3 3 module DIDKit 4 - VERSION = "0.2.1" 4 + VERSION = "0.3.1" 5 5 end
+238
spec/did_spec.rb
··· 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 1 # frozen_string_literal: true 2 2 3 - RSpec.describe Didkit do 3 + RSpec.describe DIDKit do 4 4 it "has a version number" do 5 - expect(Didkit::VERSION).not_to be nil 5 + expect(DIDKit::VERSION).not_to be nil 6 6 end 7 7 end
+209
spec/dids/bnewbold_log.json
··· 1 + [ 2 + { 3 + "did": "did:plc:44ybard66vv44zksje25o7dz", 4 + "operation": { 5 + "sig": "l1YD076TDUDX-wve_BZzhEpyqBpGx2uhcJXIgZWQKqhytnkqp6wEQ0b6VWynd3M6kryd3qxR_LULrFCVsNmtSg", 6 + "prev": null, 7 + "type": "create", 8 + "handle": "bnewbold.bsky.social", 9 + "service": "https://bsky.social", 10 + "signingKey": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ", 11 + "recoveryKey": "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg" 12 + }, 13 + "cid": "bafyreihhgaiei7xvnphgkusjgxlxy6nr3qzdaooliuku45ja5khqjrryma", 14 + "nullified": false, 15 + "createdAt": "2022-12-18T06:56:26.676Z" 16 + }, 17 + { 18 + "did": "did:plc:44ybard66vv44zksje25o7dz", 19 + "operation": { 20 + "sig": "-SIEZy39UieizioJmWpEI8ZqmecBS_inVaVq_2tlcyQYdvUbViCHtc2DQKutYzQWveeRTMAbTmHIBBf_M_oIVA", 21 + "prev": "bafyreihhgaiei7xvnphgkusjgxlxy6nr3qzdaooliuku45ja5khqjrryma", 22 + "type": "plc_operation", 23 + "services": { 24 + "atproto_pds": { 25 + "type": "AtprotoPersonalDataServer", 26 + "endpoint": "https://bsky.social" 27 + } 28 + }, 29 + "alsoKnownAs": [ 30 + "at://bnewbold.net" 31 + ], 32 + "rotationKeys": [ 33 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 34 + "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 35 + ], 36 + "verificationMethods": { 37 + "atproto": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 38 + } 39 + }, 40 + "cid": "bafyreig7a2gdfmt6kwos4xoa2h2alokeljinie5fjukn2fbbp373fsdfji", 41 + "nullified": false, 42 + "createdAt": "2023-03-06T19:01:06.544Z" 43 + }, 44 + { 45 + "did": "did:plc:44ybard66vv44zksje25o7dz", 46 + "operation": { 47 + "sig": "LorZlvU483a_t2k_j9hajm3vZkRqzXQl-XC9CtMAZ4dm14zoCejzHylpwIet3C1S0YZ-yCPDN3PQxLBBkALz2Q", 48 + "prev": "bafyreig7a2gdfmt6kwos4xoa2h2alokeljinie5fjukn2fbbp373fsdfji", 49 + "type": "plc_operation", 50 + "services": { 51 + "atproto_pds": { 52 + "type": "AtprotoPersonalDataServer", 53 + "endpoint": "https://bsky.social" 54 + } 55 + }, 56 + "alsoKnownAs": [ 57 + "at://bnewbold.net" 58 + ], 59 + "rotationKeys": [ 60 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 61 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 62 + ], 63 + "verificationMethods": { 64 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 65 + } 66 + }, 67 + "cid": "bafyreigm2b3fnzdjmnhsddb3qhzwb2vilkzqqjr5fj7lzfgypsddegs7fy", 68 + "nullified": false, 69 + "createdAt": "2023-03-09T23:18:25.251Z" 70 + }, 71 + { 72 + "did": "did:plc:44ybard66vv44zksje25o7dz", 73 + "operation": { 74 + "sig": "R-MsyrAQBnhwIns_dT4yMs86Fd-rEX9aGbn67ngyIJdeO258dahP7BU_bQz18rSXIqxPenmEnnd7diJRmdWX1A", 75 + "prev": "bafyreigm2b3fnzdjmnhsddb3qhzwb2vilkzqqjr5fj7lzfgypsddegs7fy", 76 + "type": "plc_operation", 77 + "services": { 78 + "atproto_pds": { 79 + "type": "AtprotoPersonalDataServer", 80 + "endpoint": "https://bsky.social" 81 + } 82 + }, 83 + "alsoKnownAs": [ 84 + "at://bnewbold.bsky.team" 85 + ], 86 + "rotationKeys": [ 87 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 88 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 89 + ], 90 + "verificationMethods": { 91 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 92 + } 93 + }, 94 + "cid": "bafyreigypjgiwo73gnoxixhpsaff67zzwoxeol2duxfffvqewfbz5rvv2e", 95 + "nullified": false, 96 + "createdAt": "2023-04-24T07:09:49.828Z" 97 + }, 98 + { 99 + "did": "did:plc:44ybard66vv44zksje25o7dz", 100 + "operation": { 101 + "sig": "TvAaRP_RxHG7x5hQPZv604t6WenS67xg0AD8iRJz008_j07UqBf30di1Zg8Xq235JiPGnj2Al9QdnPbxJjussQ", 102 + "prev": "bafyreigypjgiwo73gnoxixhpsaff67zzwoxeol2duxfffvqewfbz5rvv2e", 103 + "type": "plc_operation", 104 + "services": { 105 + "atproto_pds": { 106 + "type": "AtprotoPersonalDataServer", 107 + "endpoint": "https://bsky.social" 108 + } 109 + }, 110 + "alsoKnownAs": [ 111 + "at://bnewbold.net" 112 + ], 113 + "rotationKeys": [ 114 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 115 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 116 + ], 117 + "verificationMethods": { 118 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 119 + } 120 + }, 121 + "cid": "bafyreie67o3rtlz422jazne5qzy4zqpidtlzw6rlxgr2ozwydq5wklzf3m", 122 + "nullified": false, 123 + "createdAt": "2023-09-15T00:54:01.295Z" 124 + }, 125 + { 126 + "did": "did:plc:44ybard66vv44zksje25o7dz", 127 + "operation": { 128 + "sig": "pw47f-SCQOLWnXSBN5oyQVvcseit4-Mqai1jGEow28EWi4PhZ8r_Y0NX7le9jiFaQStqvqu_B2A4lnYY10Z6Iw", 129 + "prev": "bafyreie67o3rtlz422jazne5qzy4zqpidtlzw6rlxgr2ozwydq5wklzf3m", 130 + "type": "plc_operation", 131 + "services": { 132 + "atproto_pds": { 133 + "type": "AtprotoPersonalDataServer", 134 + "endpoint": "https://morel.us-east.host.bsky.network" 135 + } 136 + }, 137 + "alsoKnownAs": [ 138 + "at://bnewbold.net" 139 + ], 140 + "rotationKeys": [ 141 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 142 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 143 + ], 144 + "verificationMethods": { 145 + "atproto": "did:key:zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP" 146 + } 147 + }, 148 + "cid": "bafyreib2o3ojj3kpwybmfbdgukx7dwj63pntiv6kkxiisu7cf6n474l6qi", 149 + "nullified": false, 150 + "createdAt": "2023-11-06T23:16:23.504Z" 151 + }, 152 + { 153 + "did": "did:plc:44ybard66vv44zksje25o7dz", 154 + "operation": { 155 + "sig": "I6nqtYbH4oE8ofYIdFHy3ea55q9lv9aQSRh1VJJHYiFk4yFlaMvY_S3e4JNYW-jcyRw2bvQ3rlGGELvg84Y6kQ", 156 + "prev": "bafyreib2o3ojj3kpwybmfbdgukx7dwj63pntiv6kkxiisu7cf6n474l6qi", 157 + "type": "plc_operation", 158 + "services": { 159 + "atproto_pds": { 160 + "type": "AtprotoPersonalDataServer", 161 + "endpoint": "https://morel.us-east.host.bsky.network" 162 + } 163 + }, 164 + "alsoKnownAs": [ 165 + "at://bnewbold.net" 166 + ], 167 + "rotationKeys": [ 168 + "did:key:zDnaemZN1taothXb3MVNZhALxzsu3YzAo8c7axZEpaVGhqzkZ", 169 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 170 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 171 + ], 172 + "verificationMethods": { 173 + "atproto": "did:key:zQ3shULQPBZrbHpyf48oS68ZudUDQNdtWrhV8dafhaTFFUeGP", 174 + "example": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 175 + } 176 + }, 177 + "cid": "bafyreib5jp63rwnmhtisdn4x5fpxobp2bzfhalt4tnjg7ox7xhhlxd5wni", 178 + "nullified": false, 179 + "createdAt": "2025-06-05T20:19:47.205Z" 180 + }, 181 + { 182 + "did": "did:plc:44ybard66vv44zksje25o7dz", 183 + "operation": { 184 + "sig": "jGQssbr9_1QraxGOpNl9v12lwiEeYxhfTmSbAaRufc19YqVqr1YGRrsHYz6wwvINwK4dFEz7S1Qm_0-zdVP3Kw", 185 + "prev": "bafyreib5jp63rwnmhtisdn4x5fpxobp2bzfhalt4tnjg7ox7xhhlxd5wni", 186 + "type": "plc_operation", 187 + "services": { 188 + "atproto_pds": { 189 + "type": "AtprotoPersonalDataServer", 190 + "endpoint": "https://pds.robocracy.org" 191 + } 192 + }, 193 + "alsoKnownAs": [ 194 + "at://bnewbold.net" 195 + ], 196 + "rotationKeys": [ 197 + "did:key:zDnaemZN1taothXb3MVNZhALxzsu3YzAo8c7axZEpaVGhqzkZ", 198 + "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo" 199 + ], 200 + "verificationMethods": { 201 + "atproto": "did:key:zQ3shkke2XfFX6A1aRXkCqXKKF9m9N4GH9NCiRuDNFkpsqFmd", 202 + "example": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 203 + } 204 + }, 205 + "cid": "bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m", 206 + "nullified": false, 207 + "createdAt": "2025-06-06T00:34:40.824Z" 208 + } 209 + ]
+26
spec/dids/dholms.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 8 + "alsoKnownAs": [ 9 + "at://dholms.xyz" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co#atproto", 14 + "type": "Multikey", 15 + "controller": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 16 + "publicKeyMultibase": "zQ3shsJcHdhfpKyF3U6rBQziDHsY1ikwCAsqGWhdC1tgaPQxq" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://pds.dholms.xyz" 24 + } 25 + ] 26 + }
+26
spec/dids/witchcraft.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:web:witchcraft.systems", 8 + "alsoKnownAs": [ 9 + "at://witchcraft.systems" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:web:witchcraft.systems#atproto", 14 + "type": "Multikey", 15 + "controller": "did:web:witchcraft.systems", 16 + "publicKeyMultibase": "zQ3shqRWPzo6kSi1PDn1VXTVeaRiigsK3bxKLQ1gQ6UHqnVxW" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://pds.witchcraft.systems" 24 + } 25 + ] 26 + }
+233
spec/document_spec.rb
··· 1 + describe DIDKit::Document do 2 + subject { described_class } 3 + 4 + let(:did) { DID.new('did:plc:yk4dd2qkboz2yv6tpubpc6co') } 5 + let(:base_json) { load_did_json('dholms.json') } 6 + 7 + describe '#initialize' do 8 + context 'with valid input' do 9 + let(:json) { base_json } 10 + 11 + it 'should return a Document object' do 12 + doc = subject.new(did, json) 13 + 14 + doc.should be_a(DIDKit::Document) 15 + doc.did.should == did 16 + doc.json.should == json 17 + end 18 + 19 + it 'should parse services from the JSON' do 20 + doc = subject.new(did, json) 21 + 22 + doc.services.should be_an(Array) 23 + doc.services.length.should == 1 24 + 25 + doc.services[0].should be_a(DIDKit::ServiceRecord) 26 + doc.services[0].key.should == 'atproto_pds' 27 + doc.services[0].type.should == 'AtprotoPersonalDataServer' 28 + doc.services[0].endpoint.should == 'https://pds.dholms.xyz' 29 + end 30 + 31 + it 'should parse handles from the JSON' do 32 + doc = subject.new(did, json) 33 + 34 + doc.handles.should == ['dholms.xyz'] 35 + end 36 + end 37 + 38 + context 'when id is missing' do 39 + let(:json) { base_json.dup.tap { |h| h.delete('id') }} 40 + 41 + it 'should raise a format error' do 42 + expect { 43 + subject.new(did, json) 44 + }.to raise_error(DIDKit::FormatError) 45 + end 46 + end 47 + 48 + context 'when id is not a string' do 49 + let(:json) { base_json.merge('id' => 123) } 50 + 51 + it 'should raise a format error' do 52 + expect { 53 + subject.new(did, json) 54 + }.to raise_error(DIDKit::FormatError) 55 + end 56 + end 57 + 58 + context 'when id does not match the DID' do 59 + let(:json) { base_json.merge('id' => 'did:plc:notmatching') } 60 + 61 + it 'should raise a format error' do 62 + expect { 63 + subject.new(did, json) 64 + }.to raise_error(DIDKit::FormatError) 65 + end 66 + end 67 + 68 + context 'when alsoKnownAs is not an array' do 69 + let(:json) { base_json.merge('alsoKnownAs' => 'at://dholms.xyz') } 70 + 71 + it 'should raise an AtHandles format error' do 72 + expect { 73 + subject.new(did, json) 74 + }.to raise_error(DIDKit::FormatError) 75 + end 76 + end 77 + 78 + context 'when alsoKnownAs elements are not strings' do 79 + let(:json) { base_json.merge('alsoKnownAs' => [666]) } 80 + 81 + it 'should raise an AtHandles format error' do 82 + expect { 83 + subject.new(did, json) 84 + }.to raise_error(DIDKit::FormatError) 85 + end 86 + end 87 + 88 + context 'when alsoKnownAs contains multiple handles' do 89 + let(:json) { 90 + base_json.merge('alsoKnownAs' => [ 91 + 'at://dholms.xyz', 92 + 'https://example.com', 93 + 'at://other.handle' 94 + ]) 95 + } 96 + 97 + it 'should pick those starting with at:// and remove the prefixes' do 98 + doc = subject.new(did, json) 99 + doc.handles.should == ['dholms.xyz', 'other.handle'] 100 + end 101 + end 102 + 103 + context 'when service is not an array' do 104 + let(:json) { base_json.merge('service' => 'not-an-array') } 105 + 106 + it 'should raise a format error' do 107 + expect { 108 + subject.new(did, json) 109 + }.to raise_error(DIDKit::FormatError) 110 + end 111 + end 112 + 113 + context 'when service entries are not hashes' do 114 + let(:json) { base_json.merge('service' => ['invalid']) } 115 + 116 + it 'should raise a format error' do 117 + expect { 118 + subject.new(did, json) 119 + }.to raise_error(DIDKit::FormatError) 120 + end 121 + end 122 + 123 + context 'when service entries are partially valid' do 124 + let(:services) { 125 + [ 126 + { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, 127 + { 'id' => 'not_a_hash', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, 128 + { 'id' => '#wrong_type', 'type' => 123, 'serviceEndpoint' => 'https://pds.dholms.xyz' }, 129 + { 'id' => '#wrong_endpoint', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 123 }, 130 + { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } 131 + ] 132 + } 133 + 134 + let(:json) { base_json.merge('service' => services) } 135 + 136 + it 'should only keep the valid records' do 137 + doc = subject.new(did, json) 138 + 139 + doc.services.length.should == 2 140 + doc.services.map(&:key).should == ['atproto_pds', 'lycan'] 141 + doc.services.map(&:type).should == ['AtprotoPersonalDataServer', 'LycanService'] 142 + doc.services.map(&:endpoint).should == ['https://pds.dholms.xyz', 'https://lycan.feeds.blue'] 143 + end 144 + end 145 + end 146 + 147 + describe 'service helpers' do 148 + let(:service_json) { 149 + base_json.merge('service' => [ 150 + { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, 151 + { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz' }, 152 + { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } 153 + ]) 154 + } 155 + 156 + describe '#pds_endpoint' do 157 + it 'should return the endpoint of #atproto_pds' do 158 + doc = subject.new(did, service_json) 159 + doc.pds_endpoint.should == 'https://pds.dholms.xyz' 160 + end 161 + end 162 + 163 + describe '#pds_host' do 164 + it 'should return the host part of #atproto_pds endpoint' do 165 + doc = subject.new(did, service_json) 166 + doc.pds_host.should == 'pds.dholms.xyz' 167 + end 168 + end 169 + 170 + describe '#labeler_endpoint' do 171 + it 'should return the endpoint of #atproto_labeler' do 172 + doc = subject.new(did, service_json) 173 + doc.labeler_endpoint.should == 'https://labels.dholms.xyz' 174 + end 175 + end 176 + 177 + describe '#labeler_host' do 178 + it 'should return the host part of #atproto_labeler endpoint' do 179 + doc = subject.new(did, service_json) 180 + doc.labeler_host.should == 'labels.dholms.xyz' 181 + end 182 + end 183 + 184 + describe '#get_service' do 185 + it 'should fetch a service by key and type' do 186 + doc = subject.new(did, service_json) 187 + 188 + lycan = doc.get_service('lycan', 'LycanService') 189 + lycan.should_not be_nil 190 + lycan.endpoint.should == 'https://lycan.feeds.blue' 191 + end 192 + 193 + it 'should return nil if none of the services match' do 194 + doc = subject.new(did, service_json) 195 + 196 + result = doc.get_service('lycan', 'AtprotoLabeler') 197 + result.should be_nil 198 + 199 + result = doc.get_service('atproto_pds', 'PDS') 200 + result.should be_nil 201 + 202 + result = doc.get_service('unknown', 'Test') 203 + result.should be_nil 204 + end 205 + end 206 + 207 + it 'should expose the "labeller" aliases for endpoint and host' do 208 + doc = subject.new(did, service_json) 209 + 210 + doc.labeller_endpoint.should == 'https://labels.dholms.xyz' 211 + doc.labeller_host.should == 'labels.dholms.xyz' 212 + end 213 + 214 + describe 'if there is no matching service' do 215 + let(:service_json) { 216 + base_json.merge('service' => [ 217 + { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } 218 + ]) 219 + } 220 + 221 + it 'should return nil from the relevant methods' do 222 + doc = subject.new(did, service_json) 223 + 224 + doc.pds_endpoint.should be_nil 225 + doc.pds_host.should be_nil 226 + doc.labeller_endpoint.should be_nil 227 + doc.labeller_host.should be_nil 228 + doc.labeler_endpoint.should be_nil 229 + doc.labeler_host.should be_nil 230 + end 231 + end 232 + end 233 + end
+358
spec/plc_operation_spec.rb
··· 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
+179
spec/resolver_spec.rb
··· 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 1 # frozen_string_literal: true 2 2 3 - require "didkit" 3 + 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' 4 13 5 14 RSpec.configure do |config| 6 15 # Enable flags like --only-failures and --next-failure 7 16 config.example_status_persistence_file_path = ".rspec_status" 8 17 9 - # Disable RSpec exposing methods globally on `Module` and `main` 10 - config.disable_monkey_patching! 18 + 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 11 35 12 - config.expect_with :rspec do |c| 13 - c.syntax = :expect 36 + File.open(File.join(output_path, "index.html"), "wb") do |file| 37 + file.puts template("layout").result(binding) 38 + end 39 + end 40 + end 14 41 end 15 42 end 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