dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

add dev.atvouch.bot.membership

authored by

Luna and committed by tangled.org ad805e74 a487dec7

+542 -7
+9
appview/lib/atvouch/lexicons/membership.ex
··· 1 + defmodule Atvouch.Lexicons.Membership do 2 + use Atex.Lexicon 3 + 4 + deflexicon( 5 + Jason.decode!( 6 + File.read!(Path.join([File.cwd!(), "..", "lexicons", "dev", "atvouch", "bot", "membership.json"])) 7 + ) 8 + ) 9 + end
+113
appview/lib/atvouch/membership.ex
··· 1 + defmodule Atvouch.Membership do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + import Ecto.Query 5 + 6 + @primary_key {:repo_at_uri, :string, autogenerate: false} 7 + 8 + schema "memberships" do 9 + field(:source_did, :string) 10 + field(:repo_did, :string) 11 + field(:remote_created_at, :string) 12 + field(:received_at, :string) 13 + 14 + has_many(:maintainer_entries, Atvouch.Membership.Maintainer, foreign_key: :repo_at_uri) 15 + end 16 + 17 + def changeset(membership, attrs) do 18 + membership 19 + |> cast(attrs, [:repo_at_uri, :source_did, :repo_did, :remote_created_at, :received_at]) 20 + |> validate_required([:repo_at_uri, :source_did, :repo_did, :remote_created_at, :received_at]) 21 + |> unique_constraint(:repo_at_uri, name: :memberships_pkey) 22 + end 23 + 24 + def create(attrs, maintainer_dids) do 25 + Atvouch.Repo.transaction(fn -> 26 + case %__MODULE__{} |> changeset(attrs) |> Atvouch.Repo.insert() do 27 + {:ok, membership} -> 28 + Enum.each(maintainer_dids, fn did -> 29 + %Atvouch.Membership.Maintainer{} 30 + |> Atvouch.Membership.Maintainer.changeset(%{repo_at_uri: membership.repo_at_uri, did: did}) 31 + |> Atvouch.Repo.insert!() 32 + end) 33 + 34 + membership 35 + 36 + {:error, changeset} -> 37 + Atvouch.Repo.rollback(changeset) 38 + end 39 + end) 40 + end 41 + 42 + def update(repo_at_uri, attrs, maintainer_dids) do 43 + Atvouch.Repo.transaction(fn -> 44 + case one(repo_at_uri) do 45 + nil -> 46 + Atvouch.Repo.rollback(:not_found) 47 + 48 + membership -> 49 + case membership |> changeset(attrs) |> Atvouch.Repo.update() do 50 + {:ok, membership} -> 51 + from(m in Atvouch.Membership.Maintainer, where: m.repo_at_uri == ^repo_at_uri) 52 + |> Atvouch.Repo.delete_all() 53 + 54 + Enum.each(maintainer_dids, fn did -> 55 + %Atvouch.Membership.Maintainer{} 56 + |> Atvouch.Membership.Maintainer.changeset(%{repo_at_uri: repo_at_uri, did: did}) 57 + |> Atvouch.Repo.insert!() 58 + end) 59 + 60 + membership 61 + 62 + {:error, changeset} -> 63 + Atvouch.Repo.rollback(changeset) 64 + end 65 + end 66 + end) 67 + end 68 + 69 + def delete(repo_at_uri) do 70 + case one(repo_at_uri) do 71 + nil -> {:error, :not_found} 72 + membership -> Atvouch.Repo.delete(membership) 73 + end 74 + end 75 + 76 + def delete_by_source(source_did, rkey) do 77 + case from(m in __MODULE__, 78 + where: m.source_did == ^source_did and 79 + fragment("? LIKE '%/' || ?", m.repo_at_uri, ^rkey) 80 + ) 81 + |> Atvouch.Repo.replica().one() do 82 + nil -> {:error, :not_found} 83 + membership -> Atvouch.Repo.delete(membership) 84 + end 85 + end 86 + 87 + def one(repo_at_uri) do 88 + case Atvouch.Repo.replica().get(__MODULE__, repo_at_uri) do 89 + nil -> nil 90 + membership -> Atvouch.Repo.replica().preload(membership, :maintainer_entries) 91 + end 92 + end 93 + 94 + def maintainers(repo_at_uri) do 95 + from(m in Atvouch.Membership.Maintainer, where: m.repo_at_uri == ^repo_at_uri, select: m.did) 96 + |> Atvouch.Repo.replica().all() 97 + end 98 + 99 + def by_repo_did(repo_did) do 100 + from(m in __MODULE__, where: m.repo_did == ^repo_did, preload: [:maintainer_entries]) 101 + |> Atvouch.Repo.replica().all() 102 + end 103 + 104 + def by_maintainer_did(did) do 105 + from(m in __MODULE__, 106 + join: mt in Atvouch.Membership.Maintainer, 107 + on: mt.repo_at_uri == m.repo_at_uri, 108 + where: mt.did == ^did, 109 + preload: [:maintainer_entries] 110 + ) 111 + |> Atvouch.Repo.replica().all() 112 + end 113 + end
+23
appview/lib/atvouch/membership/maintainer.ex
··· 1 + defmodule Atvouch.Membership.Maintainer do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + @primary_key {:id, :id, autogenerate: true} 6 + 7 + schema "membership_maintainers" do 8 + belongs_to(:membership, Atvouch.Membership, 9 + references: :repo_at_uri, 10 + type: :string, 11 + foreign_key: :repo_at_uri 12 + ) 13 + 14 + field(:did, :string) 15 + end 16 + 17 + def changeset(maintainer, attrs) do 18 + maintainer 19 + |> cast(attrs, [:repo_at_uri, :did]) 20 + |> validate_required([:repo_at_uri, :did]) 21 + |> foreign_key_constraint(:repo_at_uri) 22 + end 23 + end
+98 -7
appview/lib/atvouch/tap_handler.ex
··· 2 2 @behaviour Atvouch.Tap.Handler 3 3 require Logger 4 4 5 - @collection "dev.atvouch.graph.vouch" 5 + @vouch_collection "dev.atvouch.graph.vouch" 6 + @membership_collection "dev.atvouch.bot.membership" 6 7 7 8 @impl true 8 - def handle_record(%{collection: @collection, action: :create} = event) do 9 + def handle_record(%{collection: @vouch_collection, action: :create} = event) do 9 10 case validate_record(event) do 10 11 :ok -> create_vouch(event) 11 12 ··· 15 16 end 16 17 end 17 18 18 - def handle_record(%{collection: @collection, action: :update} = event) do 19 - at_uri = "at://#{event.did}/#{@collection}/#{event.rkey}" 19 + def handle_record(%{collection: @vouch_collection, action: :update} = event) do 20 + at_uri = "at://#{event.did}/#{@vouch_collection}/#{event.rkey}" 20 21 created_at = event.record["createdAt"] 21 22 22 23 case Atvouch.Vouch.one(at_uri) do ··· 38 39 end 39 40 end 40 41 41 - def handle_record(%{collection: @collection, action: :delete} = event) do 42 - at_uri = "at://#{event.did}/#{@collection}/#{event.rkey}" 42 + def handle_record(%{collection: @vouch_collection, action: :delete} = event) do 43 + at_uri = "at://#{event.did}/#{@vouch_collection}/#{event.rkey}" 43 44 44 45 case Atvouch.Vouch.delete(at_uri) do 45 46 {:ok, _vouch} -> :ok ··· 47 48 end 48 49 end 49 50 51 + def handle_record(%{collection: @membership_collection, action: :create} = event) do 52 + case validate_membership_record(event) do 53 + :ok -> create_membership(event) 54 + {:error, reason} -> 55 + Logger.warning("Skipping invalid membership record: #{reason}") 56 + :skip 57 + end 58 + end 59 + 60 + def handle_record(%{collection: @membership_collection, action: :update} = event) do 61 + case validate_membership_record(event) do 62 + :ok -> update_membership(event) 63 + {:error, reason} -> 64 + Logger.warning("Skipping invalid membership record: #{reason}") 65 + :skip 66 + end 67 + end 68 + 69 + def handle_record(%{collection: @membership_collection, action: :delete} = event) do 70 + case Atvouch.Membership.delete_by_source(event.did, event.rkey) do 71 + {:ok, _membership} -> :ok 72 + {:error, reason} -> {:error, reason} 73 + end 74 + end 75 + 50 76 def handle_record(event) do 51 77 Logger.debug( 52 78 "explicit skip for record event #{inspect(event)}, not handled by others. is tap misconfigured?" ··· 69 95 end 70 96 71 97 defp create_vouch(event) do 72 - at_uri = "at://#{event.did}/#{@collection}/#{event.rkey}" 98 + at_uri = "at://#{event.did}/#{@vouch_collection}/#{event.rkey}" 73 99 created_at = event.record["createdAt"] 74 100 target_did = event.record["subject"] 75 101 ··· 128 154 nil -> Atvouch.Identity.create(%{did: did}) 129 155 _existing -> :ok 130 156 end 157 + end 158 + 159 + defp create_membership(event) do 160 + repo_at_uri = event.record["repo"] 161 + repo_did = extract_did_from_at_uri(repo_at_uri) 162 + maintainers = event.record["maintainers"] || [] 163 + now = DateTime.utc_now() |> DateTime.to_iso8601() 164 + 165 + case Atvouch.Membership.create( 166 + %{ 167 + repo_at_uri: repo_at_uri, 168 + source_did: event.did, 169 + repo_did: repo_did, 170 + remote_created_at: now, 171 + received_at: now 172 + }, 173 + maintainers 174 + ) do 175 + {:ok, _membership} -> :ok 176 + {:error, reason} -> {:error, reason} 177 + end 178 + end 179 + 180 + defp update_membership(event) do 181 + repo_at_uri = event.record["repo"] 182 + maintainers = event.record["maintainers"] || [] 183 + 184 + case Atvouch.Membership.update( 185 + repo_at_uri, 186 + %{}, 187 + maintainers 188 + ) do 189 + {:ok, _membership} -> :ok 190 + {:error, reason} -> {:error, reason} 191 + end 192 + end 193 + 194 + defp validate_membership_record(event) do 195 + with :ok <- validate_membership_lexicon(event.record), 196 + :ok <- validate_rkey_matches_repo(event.rkey, event.record["repo"]) do 197 + :ok 198 + end 199 + end 200 + 201 + defp validate_membership_lexicon(record) do 202 + case Atvouch.Lexicons.Membership.main(record) do 203 + {:ok, _} -> :ok 204 + {:error, errors} -> {:error, "lexicon validation failed: #{inspect(errors)}"} 205 + end 206 + end 207 + 208 + defp validate_rkey_matches_repo(rkey, repo_at_uri) when is_binary(repo_at_uri) do 209 + repo_rkey = repo_at_uri |> String.split("/") |> List.last() 210 + 211 + if rkey == repo_rkey do 212 + :ok 213 + else 214 + {:error, "rkey '#{rkey}' does not match repo rkey '#{repo_rkey}'"} 215 + end 216 + end 217 + 218 + defp validate_rkey_matches_repo(_rkey, _repo), do: {:error, "repo field is missing"} 219 + 220 + defp extract_did_from_at_uri("at://" <> rest) do 221 + rest |> String.split("/") |> List.first() 131 222 end 132 223 end
+22
appview/priv/repo/migrations/20260317000000_add_memberships.exs
··· 1 + defmodule Atvouch.Repo.Migrations.AddMemberships do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:memberships, primary_key: false) do 6 + add(:repo_at_uri, :string, primary_key: true) 7 + add(:source_did, :string, null: false) 8 + add(:repo_did, :string, null: false) 9 + add(:remote_created_at, :string, null: false) 10 + add(:received_at, :string, null: false) 11 + end 12 + 13 + create table(:membership_maintainers, primary_key: false) do 14 + add(:id, :integer, primary_key: true, autogenerate: true) 15 + add(:repo_at_uri, references(:memberships, column: :repo_at_uri, type: :string, on_delete: :delete_all), null: false) 16 + add(:did, :string, null: false) 17 + end 18 + 19 + create index(:memberships, [:repo_did]) 20 + create index(:membership_maintainers, [:repo_at_uri]) 21 + end 22 + end
+247
appview/test/atvouch/tap_handler_test.exs
··· 424 424 assert Atvouch.Vouch.one(at_uri) == nil 425 425 end 426 426 427 + test "creates a membership from a tap record event", %{port: port} do 428 + {:ok, _pid} = 429 + Atvouch.Tap.Socket.start_link( 430 + uri: "ws://localhost:#{port}/channel", 431 + handler: Atvouch.TapHandler, 432 + password: "123", 433 + name: :"tap_handler_membership_create_test_#{port}" 434 + ) 435 + 436 + assert_receive {:ws_connected, ws_pid}, 5_000 437 + 438 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 439 + repo_did = "did:plc:wamidydbgu3u6fk3yckaglnz" 440 + repo_rkey = "3mgmqjki6sz2n" 441 + repo_at_uri = "at://#{repo_did}/sh.tangled.repo/#{repo_rkey}" 442 + maintainer1 = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa" 443 + maintainer2 = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbbb" 444 + 445 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 446 + "id" => 600, 447 + "type" => "record", 448 + "record" => %{ 449 + "action" => "create", 450 + "did" => source_did, 451 + "rev" => "3mgmqjki6sz2n", 452 + "collection" => "dev.atvouch.bot.membership", 453 + "rkey" => repo_rkey, 454 + "record" => %{ 455 + "$type" => "dev.atvouch.bot.membership", 456 + "repo" => repo_at_uri, 457 + "maintainers" => [maintainer1, maintainer2] 458 + }, 459 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 460 + "live" => false 461 + } 462 + }) 463 + 464 + assert_receive {:ws_message, %{"type" => "ack", "id" => 600}}, 5_000 465 + 466 + membership = Atvouch.Membership.one(repo_at_uri) 467 + assert membership != nil 468 + assert membership.repo_at_uri == repo_at_uri 469 + assert membership.source_did == source_did 470 + assert membership.repo_did == repo_did 471 + assert membership.remote_created_at != nil 472 + assert membership.received_at != nil 473 + 474 + maintainer_dids = Atvouch.Membership.maintainers(repo_at_uri) 475 + assert Enum.sort(maintainer_dids) == Enum.sort([maintainer1, maintainer2]) 476 + end 477 + 478 + test "updates a membership's maintainers from a tap record update event", %{port: port} do 479 + {:ok, _pid} = 480 + Atvouch.Tap.Socket.start_link( 481 + uri: "ws://localhost:#{port}/channel", 482 + handler: Atvouch.TapHandler, 483 + password: "123", 484 + name: :"tap_handler_membership_update_test_#{port}" 485 + ) 486 + 487 + assert_receive {:ws_connected, ws_pid}, 5_000 488 + 489 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 490 + repo_rkey = "3mgmqjki6sz2n" 491 + repo_at_uri = "at://did:plc:wamidydbgu3u6fk3yckaglnz/sh.tangled.repo/#{repo_rkey}" 492 + maintainer1 = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa" 493 + maintainer2 = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbbb" 494 + maintainer3 = "did:plc:ccccccccccccccccccccccccc" 495 + 496 + # Create 497 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 498 + "id" => 610, 499 + "type" => "record", 500 + "record" => %{ 501 + "action" => "create", 502 + "did" => source_did, 503 + "rev" => "3mgmqjki6sz2n", 504 + "collection" => "dev.atvouch.bot.membership", 505 + "rkey" => repo_rkey, 506 + "record" => %{ 507 + "$type" => "dev.atvouch.bot.membership", 508 + "repo" => repo_at_uri, 509 + "maintainers" => [maintainer1, maintainer2] 510 + }, 511 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 512 + "live" => false 513 + } 514 + }) 515 + 516 + assert_receive {:ws_message, %{"type" => "ack", "id" => 610}}, 5_000 517 + assert Enum.sort(Atvouch.Membership.maintainers(repo_at_uri)) == Enum.sort([maintainer1, maintainer2]) 518 + 519 + # Update: replace maintainers 520 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 521 + "id" => 611, 522 + "type" => "record", 523 + "record" => %{ 524 + "action" => "update", 525 + "did" => source_did, 526 + "rev" => "3mgmqjki6sz2o", 527 + "collection" => "dev.atvouch.bot.membership", 528 + "rkey" => repo_rkey, 529 + "record" => %{ 530 + "$type" => "dev.atvouch.bot.membership", 531 + "repo" => repo_at_uri, 532 + "maintainers" => [maintainer2, maintainer3] 533 + }, 534 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmjv", 535 + "live" => false 536 + } 537 + }) 538 + 539 + assert_receive {:ws_message, %{"type" => "ack", "id" => 611}}, 5_000 540 + assert Enum.sort(Atvouch.Membership.maintainers(repo_at_uri)) == Enum.sort([maintainer2, maintainer3]) 541 + end 542 + 543 + test "deletes a membership and its maintainers from a tap record delete event", %{port: port} do 544 + {:ok, _pid} = 545 + Atvouch.Tap.Socket.start_link( 546 + uri: "ws://localhost:#{port}/channel", 547 + handler: Atvouch.TapHandler, 548 + password: "123", 549 + name: :"tap_handler_membership_delete_test_#{port}" 550 + ) 551 + 552 + assert_receive {:ws_connected, ws_pid}, 5_000 553 + 554 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 555 + repo_rkey = "3mgmqjki6sz2n" 556 + repo_at_uri = "at://did:plc:wamidydbgu3u6fk3yckaglnz/sh.tangled.repo/#{repo_rkey}" 557 + maintainer1 = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa" 558 + 559 + # Create 560 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 561 + "id" => 620, 562 + "type" => "record", 563 + "record" => %{ 564 + "action" => "create", 565 + "did" => source_did, 566 + "rev" => "3mgmqjki6sz2n", 567 + "collection" => "dev.atvouch.bot.membership", 568 + "rkey" => repo_rkey, 569 + "record" => %{ 570 + "$type" => "dev.atvouch.bot.membership", 571 + "repo" => repo_at_uri, 572 + "maintainers" => [maintainer1] 573 + }, 574 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 575 + "live" => false 576 + } 577 + }) 578 + 579 + assert_receive {:ws_message, %{"type" => "ack", "id" => 620}}, 5_000 580 + assert Atvouch.Membership.one(repo_at_uri) != nil 581 + 582 + # Delete 583 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 584 + "id" => 621, 585 + "type" => "record", 586 + "record" => %{ 587 + "action" => "delete", 588 + "did" => source_did, 589 + "rev" => "3mgmqjki6sz2o", 590 + "collection" => "dev.atvouch.bot.membership", 591 + "rkey" => repo_rkey 592 + } 593 + }) 594 + 595 + assert_receive {:ws_message, %{"type" => "ack", "id" => 621}}, 5_000 596 + assert Atvouch.Membership.one(repo_at_uri) == nil 597 + assert Atvouch.Membership.maintainers(repo_at_uri) == [] 598 + end 599 + 600 + test "rejects membership where rkey does not match repo rkey", %{port: port} do 601 + {:ok, _pid} = 602 + Atvouch.Tap.Socket.start_link( 603 + uri: "ws://localhost:#{port}/channel", 604 + handler: Atvouch.TapHandler, 605 + password: "123", 606 + name: :"tap_handler_membership_rkey_mismatch_test_#{port}" 607 + ) 608 + 609 + assert_receive {:ws_connected, ws_pid}, 5_000 610 + 611 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 612 + repo_at_uri = "at://did:plc:wamidydbgu3u6fk3yckaglnz/sh.tangled.repo/3mgmqjki6sz2n" 613 + wrong_rkey = "wrong-rkey" 614 + 615 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 616 + "id" => 630, 617 + "type" => "record", 618 + "record" => %{ 619 + "action" => "create", 620 + "did" => source_did, 621 + "rev" => "3mgmqjki6sz2n", 622 + "collection" => "dev.atvouch.bot.membership", 623 + "rkey" => wrong_rkey, 624 + "record" => %{ 625 + "$type" => "dev.atvouch.bot.membership", 626 + "repo" => repo_at_uri, 627 + "maintainers" => ["did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa"] 628 + }, 629 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 630 + "live" => false 631 + } 632 + }) 633 + 634 + assert_receive {:ws_message, %{"type" => "ack", "id" => 630}}, 5_000 635 + assert Atvouch.Membership.one(repo_at_uri) == nil 636 + end 637 + 638 + test "rejects membership with invalid repo format", %{port: port} do 639 + {:ok, _pid} = 640 + Atvouch.Tap.Socket.start_link( 641 + uri: "ws://localhost:#{port}/channel", 642 + handler: Atvouch.TapHandler, 643 + password: "123", 644 + name: :"tap_handler_membership_invalid_repo_test_#{port}" 645 + ) 646 + 647 + assert_receive {:ws_connected, ws_pid}, 5_000 648 + 649 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 650 + 651 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 652 + "id" => 640, 653 + "type" => "record", 654 + "record" => %{ 655 + "action" => "create", 656 + "did" => source_did, 657 + "rev" => "3mgmqjki6sz2n", 658 + "collection" => "dev.atvouch.bot.membership", 659 + "rkey" => "some-rkey", 660 + "record" => %{ 661 + "$type" => "dev.atvouch.bot.membership", 662 + "repo" => "not-an-at-uri", 663 + "maintainers" => ["did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa"] 664 + }, 665 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 666 + "live" => false 667 + } 668 + }) 669 + 670 + assert_receive {:ws_message, %{"type" => "ack", "id" => 640}}, 5_000 671 + # No membership should be created due to lexicon validation failure 672 + end 673 + 427 674 test "updates a vouch timestamp from a tap record update event", %{port: port} do 428 675 {:ok, _pid} = 429 676 Atvouch.Tap.Socket.start_link(
+30
lexicons/dev/atvouch/bot/membership.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "dev.atvouch.bot.membership", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "a membership record mapping a repository to its maintainers. used by the atvouch bot to track which DIDs are maintainers of a given repo. the rkey of the membership record must be equal to the rkey of the record referenced by the repo field. records that do not follow this should be considered invalid", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["repo", "maintainers"], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT URI of the repository" 17 + }, 18 + "maintainers": { 19 + "type": "array", 20 + "description": "list of DIDs that are maintainers of this repository", 21 + "items": { 22 + "type": "string", 23 + "format": "did" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }