A library for handling DID identifiers used in Bluesky AT Protocol

Compare changes

Choose any two refs to compare.

+2384 -100
+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"
+68
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 + 43 + ## [0.2.1] - 2024-03-26 44 + 45 + - tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected 46 + - added Ruby stdlib dependencies explicitly to the gemspec 47 + 48 + ## [0.2.0] - 2024-03-19 49 + 50 + - added `PLCImporter` class, which lets you import operations from PLC in pages of 1000 through the "export" API 51 + - implemented parsing of all services from DID doc & operations, not only `atproto_pds` (specifically labeller endpoints) 52 + - allow setting the nameserver in `Resolver` initializer 53 + 54 + ## [0.1.0] - 2024-03-12 55 + 56 + - rejecting handles from disallowed domains like `.arpa` or `.test` 57 + - validating handles with the `.well-known` file having a trailing newline 58 + - validating handles with `.well-known` address returning a redirect 59 + - added `#pick_valid_handle` helper 60 + - allow overriding the nameserver for `Resolv::DNS` 61 + - other bug fixes 62 + 63 + ## [0.0.4] - 2024-03-07 64 + 65 + - extracted resolving code from `DID` to a new `Resolver` class (`DID` has helper methods to call the resolver) 66 + - added `Resolver#get_validated_handle` method to validate handles from the `Document` (+ helpers in `DID` in `Document`) 67 + - added timeout to `#resolve_handle_by_well_known` 68 + 1 69 ## [0.0.3] - 2024-03-06 2 70 3 71 - added `Document#handles` with handle info extracted from `alsoKnownAs` field
+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
+100 -7
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 7 10 8 - **TODO** - not much yet :) 11 + Accounts on Bluesky use identifiers like [did:plc:oio4hkxaop4ao4wz2pp3f4cr](https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr) as unique IDs, and they also have assigned human-readable handles like [@mackuba.eu](https://bsky.app/profile/mackuba.eu), which are verified either through a DNS TXT entry or a `/.well-known/atproto-did` file. This library allows you to look up any account's assigned handle using a DID string or vice versa, load the account's DID JSON document that specifies the handles and the PDS server hosting user's repo, and check if the assigned handle verifies correctly. 12 + 13 + 14 + ## Installation 15 + 16 + To use DIDKit, you need a reasonably new version of Ruby โ€“ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)). 17 + 18 + To install the gem, run in the command line: 19 + 20 + [sudo] gem install didkit 21 + 22 + Or add this to your app's `Gemfile`: 23 + 24 + gem 'didkit', '~> 0.3' 25 + 26 + 27 + ## Usage 28 + 29 + The simplest way to use the gem is through the `DIDKit::DID` class, aliased as just `DID`: 30 + 31 + ```rb 32 + did = DID.resolve_handle('jay.bsky.team') 33 + # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo", 34 + # @resolved_by=:dns, @type=:plc> 35 + ``` 36 + 37 + This returns a `DID` object, which tells you: 38 + 39 + - the DID as a string (`#to_s` or `#did`) 40 + - the DID type (`#type`, `:plc` or `:web`) 41 + - if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`) 42 + 43 + To go in the other direction โ€“ to find an assigned and verified handle given a DID โ€“ create a `DID` from a DID string and call `get_verified_handle`: 44 + 45 + ```rb 46 + DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle 47 + # => "atproto.com" 48 + ``` 49 + 50 + You can also load the DID JSON document using `#document`, which returns a `DIDKit::Document` (`DID` caches the document, so don't worry about calling this method multiple times): 51 + 52 + ```rb 53 + did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd') 54 + 55 + did.document.handles 56 + # => ["pfrazee.com"] 57 + 58 + did.document.pds_host 59 + # => "morel.us-east.host.bsky.network" 60 + ``` 61 + 62 + 63 + ### Checking account status 64 + 65 + `DIDKit::DID` also includes a few methods for checking the status of a given account (repo), which call the `com.atproto.sync.getRepoStatus` endpoint on the account's assigned PDS: 66 + 67 + ```rb 68 + did = DID.new('did:plc:ch7azdejgddtlijyzurfdihn') 69 + did.account_status 70 + # => :takendown 71 + did.account_active? 72 + # => false 73 + did.account_exists? 74 + # => true 75 + 76 + did = DID.new('did:plc:44ybard66vv44zksje25o7dz') 77 + did.account_status 78 + # => :active 79 + did.account_active? 80 + # => true 81 + ``` 82 + 83 + ### Configuration 84 + 85 + You can customize some things about the DID/handle lookups by using the `DIDKit::Resolver` class, which the methods in `DID` use behind the scenes. 9 86 10 - See the [did.rb](https://github.com/mackuba/didkit/blob/master/lib/didkit/did.rb) file for now. 87 + Currently available options include: 11 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) 12 92 13 - ## Installation 93 + Example: 14 94 15 - gem install didkit 95 + ```rb 96 + resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8', timeout: 30) 16 97 98 + did = resolver.resolve_handle('nytimes.com') 99 + # => #<DIDKit::DID:0x0... @did="did:plc:eclio37ymobqex2ncko63h4r", 100 + # @resolved_by=:dns, @type=:plc> 101 + 102 + resolver.resolve_did(did) 103 + # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}> 104 + 105 + resolver.get_verified_handle(did) 106 + # => 'nytimes.com' 107 + ``` 17 108 18 109 ## Credits 19 110 20 - Copyright ยฉ 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 111 + Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 21 112 22 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 ๐Ÿ˜Ž
+10 -4
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' 9 + 10 + resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8') 8 11 9 12 begin 10 13 query = ARGV[0].to_s 11 14 12 - if query =~ /^did\:/ 15 + if query =~ DID::GENERIC_REGEXP 13 16 did = DID.new(query) 14 17 elsif query =~ /^@[\w\-]+(\.[\w\-]+)+$/ 15 - did = DID.resolve_handle(query) 18 + did = resolver.resolve_handle(query) 16 19 else 17 20 puts "Usage: #{$PROGRAM_NAME} <@handle> | <did:...>" 18 21 exit 1 ··· 24 27 end 25 28 26 29 doc = did.get_document 30 + verified_handle = resolver.get_verified_handle(doc) 27 31 28 32 puts 29 33 puts "PDS: #{doc.pds_endpoint}" 34 + puts "Resolved by: #{did.resolved_by}" if did.resolved_by 35 + puts "Verified handle: " + (verified_handle ? "@#{verified_handle}" : "โš ๏ธ invalid handle #{doc.handles.inspect}") 30 36 puts 31 37 puts JSON.pretty_generate(doc.json) 32 38 rescue StandardError => e
+4 -4
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
+20
lib/didkit/at_handles.rb
··· 1 + require_relative 'errors' 2 + 3 + module DIDKit 4 + 5 + # 6 + # @private 7 + # 8 + 9 + module AtHandles 10 + 11 + private 12 + 13 + def parse_also_known_as(aka) 14 + raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.is_a?(Array) 15 + raise FormatError, "Invalid alsoKnownAs: #{aka.inspect}" unless aka.all? { |x| x.is_a?(String) } 16 + 17 + aka.select { |x| x =~ %r(\Aat://[^/]+\z) }.map { |x| x.gsub('at://', '') } 18 + end 19 + end 20 + end
+156 -52
lib/didkit/did.rb
··· 1 1 require 'json' 2 - require 'net/http' 3 - require 'open-uri' 4 - require 'resolv' 2 + require 'uri' 5 3 6 - require_relative 'document' 7 4 require_relative 'errors' 5 + require_relative 'requests' 6 + require_relative 'resolver' 8 7 9 8 module DIDKit 10 - class DID 11 - def self.resolve_handle(handle) 12 - domain = handle.gsub(/^@/, '') 13 9 14 - if dns_did = resolve_handle_by_dns(domain) 15 - DID.new(dns_did, :dns) 16 - elsif http_did = resolve_handle_by_well_known(domain) 17 - DID.new(http_did, :http) 18 - else 19 - nil 20 - end 21 - end 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 + # 22 18 23 - def self.resolve_handle_by_dns(domain) 24 - dns_records = Resolv::DNS.open { |d| d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) } 19 + class DID 20 + GENERIC_REGEXP = /\Adid\:\w+\:.+\z/ 25 21 26 - if record = dns_records.first 27 - if string = record.strings.first 28 - if string =~ /^did\=(did\:\w+\:.*)$/ 29 - return $1 30 - end 31 - end 32 - end 22 + include Requests 33 23 34 - nil 24 + # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method 25 + # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods. 26 + # 27 + # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this 28 + # method to pass it an input string from the user which can be a DID or handle, without having to 29 + # check which one it is. 30 + # 31 + # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string 32 + # @return [DID, nil] resolved DID if found, nil otherwise 33 + 34 + def self.resolve_handle(handle) 35 + Resolver.new.resolve_handle(handle) 35 36 end 36 37 37 - def self.resolve_handle_by_well_known(domain) 38 - url = URI("https://#{domain}/.well-known/atproto-did") 39 - response = Net::HTTP.get_response(url) 38 + # @return [Symbol] DID type (`:plc` or `:web`) 39 + attr_reader :type 40 40 41 - if response.is_a?(Net::HTTPSuccess) 42 - if text = response.body 43 - if text.lines.length == 1 && text.start_with?('did:') 44 - return text 45 - end 46 - end 47 - end 41 + # @return [String] DID identifier string 42 + attr_reader :did 48 43 49 - nil 50 - rescue StandardError => e 51 - nil 52 - end 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 53 48 54 - attr_reader :type, :did, :resolved_by 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 55 55 56 56 def initialize(did, resolved_by = nil) 57 - if did =~ /^did\:(\w+)\:/ 57 + if did.is_a?(DID) 58 + did = did.to_s 59 + end 60 + 61 + if did =~ GENERIC_REGEXP 58 62 @did = did 59 - @type = $1.to_sym 63 + @type = did.split(':')[1].to_sym 60 64 else 61 65 raise DIDError.new("Invalid DID format") 62 66 end ··· 68 72 @resolved_by = resolved_by 69 73 end 70 74 71 - 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 72 86 73 87 def get_document 74 - type == :plc ? resolve_did_plc : resolve_did_web 88 + Resolver.new.resolve_did(self) 75 89 end 76 90 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) 101 + end 102 + 103 + # Fetches the PLC audit log (list of all previous operations) for a did:plc DID. 104 + # 105 + # @return [Array<PLCOperation>] list of PLC operations in the audit log 106 + # @raise [DIDError] when the DID is not a did:plc 107 + 108 + def get_audit_log 109 + if @type == :plc 110 + PLCImporter.new.fetch_audit_log(self) 111 + else 112 + raise DIDError.new("Audit log not supported for did:#{@type}") 113 + end 114 + end 115 + 116 + # Returns the domain portion of a did:web identifier. 117 + # 118 + # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc 119 + 77 120 def web_domain 78 121 did.gsub(/^did\:web\:/, '') if type == :web 79 122 end 80 123 81 - def resolve_did_plc 82 - url = "https://plc.directory/#{did}" 83 - json = JSON.parse(URI.open(url).read) 84 - Document.new(self, json) 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 85 160 end 86 161 87 - def resolve_did_web 88 - url = "https://#{web_domain}/.well-known/did.json" 89 - json = JSON.parse(URI.open(url).read) 90 - Document.new(self, json) 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 186 + 187 + def ==(other) 188 + if other.is_a?(String) 189 + self.did == other 190 + elsif other.is_a?(DID) 191 + self.did == other.did 192 + else 193 + false 194 + end 91 195 end 92 196 end 93 197 end
+66 -22
lib/didkit/document.rb
··· 1 + require_relative 'at_handles' 2 + require_relative 'errors' 3 + require_relative 'resolver' 4 + require_relative 'service_record' 5 + require_relative 'services' 6 + 1 7 module DIDKit 8 + 9 + # 10 + # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain. 11 + # 12 + # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object. 13 + # 14 + 2 15 class Document 3 - class FormatError < StandardError 4 - end 16 + include AtHandles 17 + include Services 5 18 6 - attr_reader :json, :did, :pds_endpoint, :handles 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. 7 41 8 42 def initialize(did, json) 9 43 raise FormatError, "Missing id field" if json['id'].nil? 10 44 raise FormatError, "Invalid id field" unless json['id'].is_a?(String) 11 - raise FormatError, "Id field doesn't match expected DID" unless json['id'] == did.to_s 45 + raise FormatError, "id field doesn't match expected DID" unless json['id'] == did.to_s 12 46 13 47 @did = did 14 48 @json = json 15 49 16 - service = json['service'] 17 - raise FormatError, "Missing service key" if service.nil? 18 - 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 19 53 20 - if pds = service.detect { |x| x['id'] == '#atproto_pds' } 21 - raise FormatError, "Missing PDS type" unless pds['type'] 22 - raise FormatError, "Invalid PDS type" unless pds['type'] == 'AtprotoPersonalDataServer' 23 - raise FormatError, "Missing PDS endpoint" unless pds['serviceEndpoint'] 24 - raise FormatError, "Invalid PDS endpoint" unless pds['serviceEndpoint'].is_a?(String) 25 - raise FormatError, "Invalid PDS endpoint" unless pds['serviceEndpoint'] =~ %r(://) 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 - @pds_endpoint = pds['serviceEndpoint'] 28 - end 62 + def get_verified_handle 63 + Resolver.new.get_verified_handle(self) 64 + end 29 65 30 - if aka = json['alsoKnownAs'] 31 - raise FormatError, "Invalid alsoKnownAs" unless aka.is_a?(Array) 32 - raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x.is_a?(String) } 33 - raise FormatError, "Invalid alsoKnownAs" unless aka.all? { |x| x =~ %r(\Aat://[^/]+\z) } 66 + 67 + private 34 68 35 - @handles = aka.map { |x| x.gsub('at://', '') } 36 - else 37 - @handles = [] 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 38 80 end 81 + 82 + services 39 83 end 40 84 end 41 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
+103
lib/didkit/plc_importer.rb
··· 1 + require 'json' 2 + require 'time' 3 + require 'uri' 4 + 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 + # 12 + 13 + module DIDKit 14 + class PLCImporter 15 + PLC_SERVICE = 'plc.directory' 16 + MAX_PAGE = 1000 17 + 18 + include Requests 19 + 20 + attr_accessor :ignore_errors, :last_date, :error_handler 21 + 22 + def initialize(since: nil) 23 + if since.to_s == 'beginning' 24 + @last_date = nil 25 + elsif since.is_a?(String) 26 + @last_date = Time.parse(since) 27 + elsif since 28 + @last_date = since 29 + else 30 + @last_date = Time.now 31 + @eof = true 32 + end 33 + 34 + @last_page_cids = [] 35 + end 36 + 37 + def plc_service 38 + PLC_SERVICE 39 + end 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 + 51 + def get_export(args = {}) 52 + url = URI("https://#{plc_service}/export") 53 + url.query = URI.encode_www_form(args) 54 + 55 + data = get_data(url, content_type: 'application/jsonlines') 56 + data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) } 57 + end 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 + 64 + def fetch_page 65 + request_time = Time.now 66 + 67 + query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {} 68 + rows = get_export(query) 69 + 70 + operations = rows.filter_map { |json| 71 + begin 72 + PLCOperation.new(json) 73 + rescue PLCOperation::FormatError, AtHandles::FormatError, ServiceRecord::FormatError => e 74 + @error_handler ? @error_handler.call(e, json) : raise 75 + nil 76 + end 77 + }.reject { |op| 78 + # when you pass the most recent op's timestamp to ?after, it will be returned as the first op again, 79 + # so we need to use this CID list to filter it out (so pages will usually be 999 items long) 80 + 81 + @last_page_cids.include?(op.cid) 82 + } 83 + 84 + @last_date = operations.last&.created_at || request_time 85 + @last_page_cids = Set.new(operations.map(&:cid)) 86 + @eof = (rows.length < MAX_PAGE) 87 + 88 + operations 89 + end 90 + 91 + def fetch(&block) 92 + loop do 93 + operations = fetch_page 94 + block.call(operations) 95 + break if eof? 96 + end 97 + end 98 + 99 + def eof? 100 + !!@eof 101 + end 102 + end 103 + end
+104
lib/didkit/plc_operation.rb
··· 1 + require 'time' 2 + 3 + require_relative 'at_handles' 4 + require_relative 'errors' 5 + require_relative 'service_record' 6 + require_relative 'services' 7 + 8 + module DIDKit 9 + 10 + # 11 + # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory) 12 + # (e.g. changing assigned handles or migrating to a different PDS). 13 + # 14 + 15 + class PLCOperation 16 + include AtHandles 17 + include Services 18 + 19 + # @return [Hash] the JSON from which the operation is parsed 20 + attr_reader :json 21 + 22 + # @return [String] the DID which the operation concerns 23 + attr_reader :did 24 + 25 + # @return [String] CID (Content Identifier) of the operation 26 + attr_reader :cid 27 + 28 + # Returns a sequential number of the operation (only used in the new export API). 29 + # @return [Integer, nil] sequential number of the operation 30 + attr_reader :seq 31 + 32 + # @return [Time] time when the operation was created 33 + attr_reader :created_at 34 + 35 + # Returns the `type` field of the operation (usually `"plc_operation"`). 36 + # @return [String] the operation type 37 + attr_reader :type 38 + 39 + # Returns a list of handles assigned to the DID in this operation. 40 + # 41 + # Note: the handles aren't guaranteed to be verified (validated in the other direction). 42 + # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is 43 + # correctly verified. 44 + # 45 + # @return [Array<String>] 46 + attr_reader :handles 47 + 48 + # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID 49 + attr_reader :services 50 + 51 + 52 + # Creates a PLCOperation object. 53 + # 54 + # @param json [Hash] operation JSON 55 + # @raise [FormatError] when required fields are missing or invalid 56 + 57 + def initialize(json) 58 + @json = json 59 + raise FormatError, "Expected argument to be a Hash, got a #{json.class}" unless @json.is_a?(Hash) 60 + 61 + @seq = json['seq'] 62 + @did = json['did'] 63 + raise FormatError, "Missing DID: #{json}" if @did.nil? 64 + raise FormatError, "Invalid DID: #{@did.inspect}" unless @did.is_a?(String) && @did.start_with?('did:') 65 + 66 + @cid = json['cid'] 67 + raise FormatError, "Missing CID: #{json}" if @cid.nil? 68 + raise FormatError, "Invalid CID: #{@cid}" unless @cid.is_a?(String) 69 + 70 + timestamp = json['createdAt'] 71 + raise FormatError, "Missing createdAt: #{json}" if timestamp.nil? 72 + raise FormatError, "Invalid createdAt: #{timestamp.inspect}" unless timestamp.is_a?(String) 73 + 74 + @created_at = Time.parse(timestamp) 75 + 76 + operation = json['operation'] 77 + raise FormatError, "Missing operation key: #{json}" if operation.nil? 78 + raise FormatError, "Invalid operation data: #{operation.inspect}" unless operation.is_a?(Hash) 79 + 80 + type = operation['type'] 81 + raise FormatError, "Missing operation type: #{json}" if type.nil? 82 + 83 + @type = type.to_sym 84 + return unless @type == :plc_operation 85 + 86 + services = operation['services'] 87 + raise FormatError, "Missing services key: #{json}" if services.nil? 88 + raise FormatError, "Invalid services data: #{services}" unless services.is_a?(Hash) 89 + 90 + @services = services.map { |k, x| 91 + type, endpoint = x.values_at('type', 'endpoint') 92 + 93 + raise FormatError, "Missing service type" unless type 94 + raise FormatError, "Invalid service type: #{type.inspect}" unless type.is_a?(String) 95 + raise FormatError, "Missing service endpoint" unless endpoint 96 + raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" unless endpoint.is_a?(String) 97 + 98 + ServiceRecord.new(k, type, endpoint) 99 + } 100 + 101 + @handles = parse_also_known_as(operation['alsoKnownAs']) 102 + end 103 + end 104 + end
+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
+173
lib/didkit/resolver.rb
··· 1 + require 'net/http' 2 + require 'resolv' 3 + 4 + require_relative 'did' 5 + require_relative 'document' 6 + require_relative 'requests' 7 + 8 + module DIDKit 9 + 10 + # 11 + # A class which manages resolving of handles to DIDs and DIDs to DID documents. 12 + # 13 + 14 + class Resolver 15 + # These TLDs are not allowed in ATProto handles, so the resolver returns nil for them 16 + # without trying to look them up. 17 + RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test) 18 + 19 + include Requests 20 + 21 + # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups 22 + attr_accessor :nameserver 23 + 24 + # @param options [Hash] resolver options 25 + # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs) 26 + # @option options [Integer] :timeout request timeout in seconds (default: 15) 27 + # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5) 28 + 29 + def initialize(options = {}) 30 + @nameserver = options[:nameserver] 31 + @request_options = options.slice(:timeout, :max_redirects) 32 + end 33 + 34 + # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method 35 + # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods. 36 + # 37 + # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this 38 + # method to pass it an input string from the user which can be a DID or handle, without having to 39 + # check which one it is. 40 + # 41 + # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string 42 + # @return [DID, nil] resolved DID if found, nil otherwise 43 + 44 + def resolve_handle(handle) 45 + if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP 46 + return DID.new(handle) 47 + end 48 + 49 + domain = handle.gsub(/^@/, '') 50 + 51 + return nil if RESERVED_DOMAINS.include?(domain.split('.').last) 52 + 53 + if dns_did = resolve_handle_by_dns(domain) 54 + DID.new(dns_did, :dns) 55 + elsif http_did = resolve_handle_by_well_known(domain) 56 + DID.new(http_did, :http) 57 + else 58 + nil 59 + end 60 + end 61 + 62 + # Tries to resolve a handle into DID using the DNS TXT method. 63 + # 64 + # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is 65 + # a correct DID string. 66 + # 67 + # @param domain [String] a domain handle to look up 68 + # @return [String, nil] resolved DID if found, nil otherwise 69 + 70 + def resolve_handle_by_dns(domain) 71 + dns_records = Resolv::DNS.open(resolv_options) do |d| 72 + d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT) 73 + end 74 + 75 + if record = dns_records.first 76 + if string = record.strings.first 77 + return parse_did_from_dns(string) 78 + end 79 + end 80 + 81 + nil 82 + end 83 + 84 + # Tries to resolve a handle into DID using the HTTP .well-known method. 85 + # 86 + # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns 87 + # a text file that contains a correct DID string. 88 + # 89 + # @param domain [String] a domain handle to look up 90 + # @return [String, nil] resolved DID if found, nil otherwise 91 + 92 + def resolve_handle_by_well_known(domain) 93 + url = "https://#{domain}/.well-known/atproto-did" 94 + response = get_response(url, @request_options) 95 + 96 + if response.is_a?(Net::HTTPSuccess) && (text = response.body) 97 + return parse_did_from_well_known(text) 98 + end 99 + 100 + nil 101 + rescue StandardError => e 102 + nil 103 + end 104 + 105 + # Resolve a DID to a DID document. 106 + # 107 + # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either 108 + # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs. 109 + # 110 + # @param did [String, DID] DID string or object 111 + # @return [Document] resolved DID document 112 + # @raise [APIError] if an incorrect response is returned 113 + 114 + def resolve_did(did) 115 + did = DID.new(did) if did.is_a?(String) 116 + 117 + did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did) 118 + end 119 + 120 + # Returns the first verified handle assigned to the given DID. 121 + # 122 + # Looks up the domain handles assigned to the DID in the DID document, checks if they are 123 + # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns 124 + # the first handle that validates correctly, or nil if none matches. 125 + # 126 + # @param subject [String, DID, Document] a DID or its DID document 127 + # @return [String, nil] verified handle domain, if found 128 + 129 + def get_verified_handle(subject) 130 + document = subject.is_a?(Document) ? subject : resolve_did(subject) 131 + 132 + first_verified_handle(document.did, document.handles) 133 + end 134 + 135 + # Returns the first handle from the list that resolves back to the given DID. 136 + # 137 + # @param did [DID, String] DID to verify the handles against 138 + # @param handles [Array<String>] handles to check 139 + # @return [String, nil] a verified handle, if found 140 + 141 + def first_verified_handle(did, handles) 142 + handles.detect { |h| resolve_handle(h) == did.to_s } 143 + end 144 + 145 + 146 + private 147 + 148 + def resolv_options 149 + options = Resolv::DNS::Config.default_config_hash.dup 150 + options[:nameserver] = nameserver if nameserver 151 + options 152 + end 153 + 154 + def parse_did_from_dns(txt) 155 + txt =~ /\Adid\=(did\:\w+\:.*)\z/ ? $1 : nil 156 + end 157 + 158 + def parse_did_from_well_known(text) 159 + text = text.strip 160 + text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil 161 + end 162 + 163 + def resolve_did_plc(did) 164 + json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/) 165 + Document.new(did, json) 166 + end 167 + 168 + def resolve_did_web(did) 169 + json = get_json("https://#{did.web_domain}/.well-known/did.json") 170 + Document.new(did, json) 171 + end 172 + end 173 + end
+41
lib/didkit/service_record.rb
··· 1 + require 'uri' 2 + require_relative 'errors' 3 + 4 + module DIDKit 5 + 6 + # A parsed service record from either a DID document's `service` field or a PLC directory 7 + # operation's `services` field. 8 + 9 + class ServiceRecord 10 + 11 + # Returns the service's identifier (without `#`), like "atproto_pds". 12 + # @return [String] service's identifier 13 + attr_reader :key 14 + 15 + # Returns the service's type field, like "AtprotoPersonalDataServer". 16 + # @return [String] service's type 17 + attr_reader :type 18 + 19 + # @return [String] service's endpoint URL 20 + attr_reader :endpoint 21 + 22 + # Create a service record from DID document fields. 23 + # 24 + # @param key [String] service identifier (without `#`) 25 + # @param type [String] service type 26 + # @param endpoint [String] service endpoint URL 27 + # @raise [FormatError] when the endpoint is not a valid URI 28 + 29 + def initialize(key, type, endpoint) 30 + begin 31 + uri = URI(endpoint) 32 + rescue URI::Error 33 + raise FormatError, "Invalid service endpoint: #{endpoint.inspect}" 34 + end 35 + 36 + @key = key 37 + @type = type 38 + @endpoint = endpoint 39 + end 40 + end 41 + end
+68
lib/didkit/services.rb
··· 1 + require 'uri' 2 + 3 + module DIDKit 4 + 5 + # 6 + # @api private 7 + # 8 + 9 + module Services 10 + 11 + # Finds a service entry matching the given key and type. 12 + # 13 + # @api public 14 + # @param key [String] service key in the DID document 15 + # @param type [String] service type identifier 16 + # @return [ServiceRecord, nil] matching service record, if found 17 + 18 + def get_service(key, type) 19 + @services&.detect { |s| s.key == key && s.type == type } 20 + end 21 + 22 + # Returns the PDS service endpoint, if present. 23 + # 24 + # If the DID has an `#atproto_pds` service declared in its `service` section, 25 + # returns the URL in its `serviceEndpoint` field. In other words, this is the URL 26 + # of the PDS assigned to a given user, which stores the user's account and repo. 27 + # 28 + # @api public 29 + # @return [String, nil] PDS service endpoint URL 30 + 31 + def pds_endpoint 32 + @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint 33 + end 34 + 35 + # Returns the labeler service endpoint, if present. 36 + # 37 + # If the DID has an `#atproto_labeler` service declared in its `service` section, 38 + # returns the URL in its `serviceEndpoint` field. 39 + # 40 + # @api public 41 + # @return [String, nil] labeler service endpoint URL 42 + 43 + def labeler_endpoint 44 + @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint 45 + end 46 + 47 + # Returns the hostname of the PDS service, if present. 48 + # 49 + # @api public 50 + # @return [String, nil] hostname of the PDS endpoint URL 51 + 52 + def pds_host 53 + pds_endpoint&.then { |x| URI(x).host } 54 + end 55 + 56 + # Returns the hostname of the labeler service, if present. 57 + # 58 + # @api public 59 + # @return [String, nil] hostname of the labeler endpoint URL 60 + 61 + def labeler_host 62 + labeler_endpoint&.then { |x| URI(x).host } 63 + end 64 + 65 + alias labeller_endpoint labeler_endpoint 66 + alias labeller_host labeler_host 67 + end 68 + end
+1 -1
lib/didkit/version.rb
··· 1 1 # frozen_string_literal: true 2 2 3 3 module DIDKit 4 - VERSION = "0.0.3" 4 + VERSION = "0.3.1" 5 5 end
+3
lib/didkit.rb
··· 2 2 3 3 require_relative "didkit/did" 4 4 require_relative "didkit/document" 5 + require_relative "didkit/plc_importer" 6 + require_relative "didkit/plc_operation" 7 + require_relative "didkit/resolver" 5 8 require_relative "didkit/version" 6 9 7 10 module DIDKit
+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