oydid 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3e561caa744ec1b341317d142b27f5b9d7d9dddf92b2d18aaf2117fff05cb9a
4
- data.tar.gz: 6aa242687b1765148f79e9b6911d22e81339159f2f0c45445c0be1ce804fe0ad
3
+ metadata.gz: 9c74b5cd9e592efe1514c9110cff25a131ddc0a45727e035d48656bc0c20d2f8
4
+ data.tar.gz: bd936eb03e018e15ae334907c7087629f48f17c2cc54bdeebb8a81542e2f2af9
5
5
  SHA512:
6
- metadata.gz: 35d735d7f16edf812645b91034b5c8824437144fb86b2dba19e2b165a1912279fc3e6b09916cde37004402bb309f5faad7248105b41de07f293892f7e6963d09
7
- data.tar.gz: 0e333ede2a2b46e9c6264264f638cf3b54823cab9663a11af3bf776f796965210abb39b3c37555d0824f67d9b55506d3065803b1a94182097acdf0b11b9c670e
6
+ metadata.gz: 8fddcdf2ce2328ba9329263b1de2826cec3c3d4947c7cd5e599972798cffb10887e8bf2205d208961494a7296a818c90a4fea679f2b9b1c01109645c8fde59bf
7
+ data.tar.gz: fdaab3ac52a14b4313db2d7d52058a41bdd6b72b2ca580441f5620a246d73d2fdf753a0530aef76d44520d8347573852aa6e3ae27d173371d54b09eeb86c874c
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.0
1
+ 0.9.1
data/lib/oydid/basic.rb CHANGED
@@ -1216,6 +1216,24 @@ class Oydid
1216
1216
  end
1217
1217
  end
1218
1218
 
1219
+ # The identifier without its location suffix.
1220
+ #
1221
+ # A did:oyd can carry "@<location>" (raw or percent-encoded) to say where the
1222
+ # document is hosted. That is a resolution hint, not part of the identity:
1223
+ # the same document can be mirrored at any number of locations, so the set of
1224
+ # location-bound variants is open and cannot be enumerated. Everything that
1225
+ # states identity - canonicalId and equivalentId in the didDocumentMetadata -
1226
+ # therefore uses this location-free form. The location-bound variant stays in
1227
+ # alsoKnownAs.
1228
+ #
1229
+ # Note this must NOT be applied to the id of the DID document itself: DID Core
1230
+ # requires that "the value of the id property in the retrieved DID document
1231
+ # must always match the DID being resolved", so a location-bound DID that was
1232
+ # requested is echoed as requested (see Oydid.document_id).
1233
+ def self.strip_location(id)
1234
+ id.to_s.split(LOCATION_PREFIX).first.split(CGI.escape LOCATION_PREFIX).first rescue id.to_s
1235
+ end
1236
+
1219
1237
  def self.get_location(id)
1220
1238
  if id.include?(LOCATION_PREFIX)
1221
1239
  id_split = id.split(LOCATION_PREFIX)
data/lib/oydid.rb CHANGED
@@ -1446,19 +1446,42 @@ class Oydid
1446
1446
  # uniresolver plugin) and they had drifted into computing this list with
1447
1447
  # different rules.
1448
1448
  #
1449
+ # Both values are location-free: the "@<location>" suffix says where a
1450
+ # document is hosted, not who it is, and the same document can be mirrored at
1451
+ # any number of locations - so the set of location-bound variants is open and
1452
+ # equivalentId could not state it correctly anyway. The raw values these are
1453
+ # built from do carry a location (dag_update sets did_info["did"] from the log
1454
+ # entry, and log[]["doc"] is written with one), which is why strip_location is
1455
+ # applied here rather than assumed. The location-bound variant stays in
1456
+ # alsoKnownAs; document_id is deliberately left alone (see strip_location).
1457
+ #
1449
1458
  # Returns [canonicalId, equivalentIds]; equivalentIds never contains the
1450
- # identifier the DID document itself carries as `id` (see document_id).
1451
- def self.version_ids(did_info)
1452
- canonical = percent_encode(did_info["did"].to_s)
1453
- if !canonical.start_with?("did:oyd:")
1454
- canonical = "did:oyd:" + canonical
1455
- end
1459
+ # identifier the DID document itself carries as `id` (see document_id) and is
1460
+ # empty - not a set with the DID itself in it - while there is only one
1461
+ # version.
1462
+ # keep_location = true reproduces the pre-0.9.1 values, location suffix and
1463
+ # all. Only w3c uses it, to keep alsoKnownAs listing the location-bound
1464
+ # variant of a DID - dropping that would take the location out of the
1465
+ # document altogether, and alsoKnownAs is where it belongs.
1466
+ #
1467
+ # Deliberately a positional argument: callers pass did_info as a braceless
1468
+ # hash literal (version_ids("did" => ..., "log" => ...)), and a method with
1469
+ # a keyword parameter swallows that hash as keywords instead - every such
1470
+ # call site would raise ArgumentError.
1471
+ def self.version_ids(did_info, keep_location = false)
1472
+ normalize = lambda do |id|
1473
+ id = keep_location ? id.to_s : strip_location(id.to_s)
1474
+ id = percent_encode(id)
1475
+ id = "did:oyd:" + id if !id.start_with?("did:oyd:")
1476
+ id
1477
+ end
1478
+ canonical = normalize.call(did_info["did"])
1456
1479
  own = document_id(did_info)
1457
1480
  equivalentIds = []
1458
1481
  did_info["log"].each do |log|
1459
1482
  if log["op"] == 2 || log["op"] == 3
1460
- eid = percent_encode("did:oyd:" + log["doc"].to_s)
1461
- if eid != own
1483
+ eid = normalize.call(log["doc"])
1484
+ if eid != own && !equivalentIds.include?(eid)
1462
1485
  equivalentIds << eid
1463
1486
  end
1464
1487
  end
@@ -1466,6 +1489,52 @@ class Oydid
1466
1489
  [canonical, equivalentIds]
1467
1490
  end
1468
1491
 
1492
+ # created / updated / versionId of the resolved document version, as DID Core
1493
+ # 7.1.3 defines them. Returned as a hash with string keys, ready to be merged
1494
+ # into didDocumentMetadata; a property the log cannot answer is absent rather
1495
+ # than null - in particular `updated`, which the spec requires to be "omitted
1496
+ # if an Update operation has never been performed on the DID document".
1497
+ #
1498
+ # The resolved version is did_info["did"] - dag_update walks the log to the
1499
+ # newest document and leaves its identifier there. Deriving both versionId and
1500
+ # the `updated` entry from it avoids guessing which log entry is the newest,
1501
+ # which timestamps alone cannot decide (they are client-supplied and two
1502
+ # entries can share a second).
1503
+ #
1504
+ # versionId is the bare document hash, without the "did:oyd:" prefix and
1505
+ # without a location: it is the method-specific identifier of that version, so
1506
+ # "did:oyd:" + versionId is the versioned DID.
1507
+ def self.version_metadata(did_info)
1508
+ as_datetime = lambda do |ts|
1509
+ # XML Datetime normalised to UTC, no sub-second precision (7.1.3)
1510
+ Time.at(ts.to_i).utc.strftime("%Y-%m-%dT%H:%M:%SZ") rescue nil
1511
+ end
1512
+ version_of = lambda do |id|
1513
+ strip_location(id.to_s).delete_prefix("did:oyd:")
1514
+ end
1515
+
1516
+ meta = {}
1517
+ resolved = version_of.call(did_info["did"])
1518
+ meta["versionId"] = resolved if resolved != ""
1519
+ return meta if did_info["log"].nil?
1520
+
1521
+ created_entry = did_info["log"].find { |el| el["op"].to_i == 2 }
1522
+ if !created_entry.nil? && !created_entry["ts"].nil?
1523
+ created = as_datetime.call(created_entry["ts"])
1524
+ meta["created"] = created if !created.nil?
1525
+ end
1526
+
1527
+ updated_entry = did_info["log"].find do |el|
1528
+ el["op"].to_i == 3 && version_of.call(el["doc"]) == resolved
1529
+ end
1530
+ if !updated_entry.nil? && !updated_entry["ts"].nil?
1531
+ updated = as_datetime.call(updated_entry["ts"])
1532
+ meta["updated"] = updated if !updated.nil?
1533
+ end
1534
+
1535
+ meta
1536
+ end
1537
+
1469
1538
  # The identifier a resolved DID document carries as `id`.
1470
1539
  #
1471
1540
  # A did:oyd is the hash over its own document, so an update mints a new one
@@ -1632,8 +1701,12 @@ class Oydid
1632
1701
  # is reciprocated - which it cannot be here, because all versions resolve
1633
1702
  # to the same document. It stays for backwards compatibility; the
1634
1703
  # authoritative statement is didDocumentMetadata canonicalId/equivalentId,
1635
- # built from the same list.
1636
- equivalentIds = version_ids(did_info).last
1704
+ # built from the same list - except that this one keeps the location
1705
+ # suffix. alsoKnownAs therefore carries two kinds of statement: other
1706
+ # versions of the DID, and the location-bound variant of one. It must not
1707
+ # be read as a list of locations; the method specification says so
1708
+ # explicitly.
1709
+ equivalentIds = version_ids(did_info, true).last
1637
1710
  if equivalentIds.length > 0
1638
1711
  wd["alsoKnownAs"] = equivalentIds
1639
1712
  end
data/spec/oydid_spec.rb CHANGED
@@ -626,12 +626,80 @@ describe "OYDID handling" do
626
626
  expect(canonical).to eq "did:oyd:" + second_did
627
627
  end
628
628
 
629
- it "percent-encodes a location suffix" do
629
+ # The identifier a relying party was handed is the one it asked for, which
630
+ # after an update is an earlier version than the document being served. Every
631
+ # other version is then equivalent to it - not just the current one. This is
632
+ # the case the HTTP request specs could not reach before update_did existed.
633
+ it "lists every other version when an earlier one was requested" do
634
+ canonical, equivalent = Oydid.version_ids(
635
+ "did" => "did:oyd:zC",
636
+ "did_requested" => "did:oyd:zA",
637
+ "log" => [{ "op" => 2, "doc" => "zA" },
638
+ { "op" => 3, "doc" => "zB" },
639
+ { "op" => 3, "doc" => "zC" }])
640
+ expect(canonical).to eq "did:oyd:zC"
641
+ expect(equivalent).to eq ["did:oyd:zB", "did:oyd:zC"]
642
+ end
643
+
644
+ it "lists the versions on both sides when a middle one was requested" do
645
+ canonical, equivalent = Oydid.version_ids(
646
+ "did" => "did:oyd:zC",
647
+ "did_requested" => "did:oyd:zB",
648
+ "log" => [{ "op" => 2, "doc" => "zA" },
649
+ { "op" => 3, "doc" => "zB" },
650
+ { "op" => 3, "doc" => "zC" }])
651
+ expect(canonical).to eq "did:oyd:zC"
652
+ expect(equivalent).to eq ["did:oyd:zA", "did:oyd:zC"]
653
+ end
654
+
655
+ # canonicalId and equivalentId state identity, and "@<location>" states where
656
+ # a document is hosted. The same document can be mirrored at any number of
657
+ # locations, so the set of location-bound variants is open and equivalentId
658
+ # could not state it correctly - both are therefore location-free.
659
+ it "strips the location suffix from canonicalId and equivalentId" do
660
+ canonical, equivalent = Oydid.version_ids(
661
+ "did" => "did:oyd:" + second_did + "@https://example.org",
662
+ "did_requested" => "did:oyd:" + second_did,
663
+ "log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
664
+ { "op" => 3, "doc" => second_did + "@https://example.org" }])
665
+ expect(canonical).to eq "did:oyd:" + second_did
666
+ expect(equivalent).to eq ["did:oyd:" + first_did]
667
+ end
668
+
669
+ # A location-bound DID that was asked for stays the id of the document - DID
670
+ # Core: "the value of the id property in the retrieved DID document must
671
+ # always match the DID being resolved". Its location-free form is then a
672
+ # genuinely different string for the same subject, so it belongs in
673
+ # equivalentId alongside the earlier version.
674
+ it "lists the location-free form of the current version when a location-bound DID was requested" do
630
675
  canonical, equivalent = Oydid.version_ids(
631
676
  "did" => "did:oyd:" + second_did + "@https://example.org",
677
+ "did_requested" => "did:oyd:" + second_did + "@https://example.org",
632
678
  "log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
633
679
  { "op" => 3, "doc" => second_did + "@https://example.org" }])
634
- expect(canonical).to eq "did:oyd:" + second_did + "%40example.org"
680
+ expect(canonical).to eq "did:oyd:" + second_did
681
+ expect(equivalent).to eq ["did:oyd:" + first_did, "did:oyd:" + second_did]
682
+ end
683
+
684
+ # this used to list the DID as its own equivalent, in location-bound form,
685
+ # and hand out that same string as canonicalId
686
+ it "reports no equivalents for a never updated DID served from a location" do
687
+ canonical, equivalent = Oydid.version_ids(
688
+ "did" => "did:oyd:" + first_did + "@https://example.org",
689
+ "did_requested" => "did:oyd:" + first_did,
690
+ "log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" },
691
+ { "op" => 0, "doc" => "terminate" }])
692
+ expect(canonical).to eq "did:oyd:" + first_did
693
+ expect(equivalent).to eq []
694
+ end
695
+
696
+ it "keeps the location suffix for the alsoKnownAs list" do
697
+ canonical, equivalent = Oydid.version_ids(
698
+ { "did" => "did:oyd:" + first_did + "@https://example.org",
699
+ "did_requested" => "did:oyd:" + first_did,
700
+ "log" => [{ "op" => 2, "doc" => first_did + "@https://example.org" }] },
701
+ true)
702
+ expect(canonical).to eq "did:oyd:" + first_did + "%40example.org"
635
703
  expect(equivalent).to eq ["did:oyd:" + first_did + "%40example.org"]
636
704
  end
637
705
 
@@ -661,7 +729,78 @@ describe "OYDID handling" do
661
729
  }
662
730
  wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
663
731
  expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + first_did]
664
- expect(wd["alsoKnownAs"]).to eq Oydid.version_ids(did_info).last
732
+ expect(wd["alsoKnownAs"]).to eq Oydid.version_ids(did_info, true).last
733
+ end
734
+
735
+ # The two lists deliberately part ways on the location: identity statements
736
+ # drop it, alsoKnownAs keeps it - otherwise the location would disappear from
737
+ # the DID document altogether. Guards the live output for a never updated DID.
738
+ it "keeps the location-bound variant in alsoKnownAs while canonicalId drops it" do
739
+ stubbed_did = "zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh"
740
+ did_info = {
741
+ "did" => "did:oyd:" + stubbed_did + "@https://example.org",
742
+ "did_requested" => "did:oyd:" + stubbed_did,
743
+ "doc" => { "doc" => { "hello" => "world" },
744
+ "key" => "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S:" \
745
+ "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK" },
746
+ "log" => [{ "op" => 2, "doc" => stubbed_did + "@https://example.org" }]
747
+ }
748
+ wd = Oydid.w3c(Marshal.load(Marshal.dump(did_info)), {})
749
+ expect(wd["id"]).to eq "did:oyd:" + stubbed_did
750
+ expect(wd["alsoKnownAs"]).to eq ["did:oyd:" + stubbed_did + "%40example.org"]
751
+ canonical, equivalent = Oydid.version_ids(did_info)
752
+ expect(canonical).to eq "did:oyd:" + stubbed_did
753
+ expect(equivalent).to eq []
754
+ end
755
+ end
756
+
757
+ # created / updated / versionId of the resolved version (DID Core 7.1.3).
758
+ # Without `updated` a consumer cannot tell how old the document in its hands is.
759
+ describe "version_metadata" do
760
+ let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
761
+ let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
762
+
763
+ # "The updated property is omitted if an Update operation has never been
764
+ # performed on the DID document." - 7.1.3
765
+ it "reports created and versionId, and omits updated, for a new DID" do
766
+ meta = Oydid.version_metadata(
767
+ "did" => "did:oyd:" + first_did + "@https://example.org",
768
+ "log" => [{ "op" => 2, "ts" => 1641224940, "doc" => first_did + "@https://example.org" },
769
+ { "op" => 0, "ts" => 1641224940, "doc" => "terminate" }])
770
+ expect(meta["created"]).to eq "2022-01-03T15:49:00Z"
771
+ expect(meta["versionId"]).to eq first_did
772
+ expect(meta).not_to have_key("updated")
773
+ end
774
+
775
+ it "reports the timestamp of the update that produced the resolved version" do
776
+ meta = Oydid.version_metadata(
777
+ "did" => "did:oyd:" + second_did,
778
+ "log" => [{ "op" => 2, "ts" => 1641224940, "doc" => first_did },
779
+ { "op" => 3, "ts" => 1641225032, "doc" => second_did },
780
+ { "op" => 0, "ts" => 1641225032, "doc" => "terminate" }])
781
+ expect(meta["created"]).to eq "2022-01-03T15:49:00Z"
782
+ expect(meta["updated"]).to eq "2022-01-03T15:50:32Z"
783
+ expect(meta["versionId"]).to eq second_did
784
+ end
785
+
786
+ # the entry is picked by the version it produced, not by its timestamp:
787
+ # timestamps come from the client and two entries can share a second
788
+ it "ignores updates that did not produce the resolved version" do
789
+ meta = Oydid.version_metadata(
790
+ "did" => "did:oyd:zB",
791
+ "log" => [{ "op" => 2, "ts" => 1, "doc" => "zA" },
792
+ { "op" => 3, "ts" => 2, "doc" => "zB" },
793
+ { "op" => 3, "ts" => 3, "doc" => "zC" }])
794
+ expect(meta["versionId"]).to eq "zB"
795
+ expect(meta["updated"]).to eq "1970-01-01T00:00:02Z"
796
+ end
797
+
798
+ it "leaves out what the log cannot answer" do
799
+ expect(Oydid.version_metadata("did" => "did:oyd:" + first_did))
800
+ .to eq({ "versionId" => first_did })
801
+ expect(Oydid.version_metadata("did" => "did:oyd:" + first_did,
802
+ "log" => [{ "op" => 2, "doc" => first_did }]))
803
+ .to eq({ "versionId" => first_did })
665
804
  end
666
805
  end
667
806
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oydid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-23 00:00:00.000000000 Z
10
+ date: 2026-08-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag