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.

hook repo name to membership record

authored by

Luna and committed by tangled.org 569bf85b ae93f584

+464 -708
+82
appview/lib/atvouch/atproto.ex
··· 1 + defmodule Atvouch.Atproto do 2 + @moduledoc """ 3 + AT Protocol helpers for resolving DID documents and fetching records from PDS instances. 4 + """ 5 + 6 + require Logger 7 + 8 + @plc_directory "https://plc.directory" 9 + 10 + @doc """ 11 + Resolve a DID to its PDS service endpoint URL. 12 + """ 13 + def resolve_pds_url("did:plc:" <> _ = did) do 14 + url = "#{@plc_directory}/#{did}" 15 + 16 + case Tesla.get(url) do 17 + {:ok, %Tesla.Env{status: 200, body: body}} -> 18 + doc = if is_binary(body), do: Jason.decode!(body), else: body 19 + extract_pds_endpoint(doc) 20 + 21 + {:ok, %Tesla.Env{status: status}} -> 22 + {:error, {:plc_resolve_failed, status}} 23 + 24 + {:error, reason} -> 25 + {:error, {:plc_resolve_failed, reason}} 26 + end 27 + end 28 + 29 + def resolve_pds_url("did:web:" <> domain) do 30 + url = "https://#{domain}/.well-known/did.json" 31 + 32 + case Tesla.get(url) do 33 + {:ok, %Tesla.Env{status: 200, body: body}} -> 34 + doc = if is_binary(body), do: Jason.decode!(body), else: body 35 + extract_pds_endpoint(doc) 36 + 37 + {:ok, %Tesla.Env{status: status}} -> 38 + {:error, {:web_did_resolve_failed, status}} 39 + 40 + {:error, reason} -> 41 + {:error, {:web_did_resolve_failed, reason}} 42 + end 43 + end 44 + 45 + def resolve_pds_url(_did), do: {:error, :unsupported_did_method} 46 + 47 + @doc """ 48 + Fetch a record from a PDS via com.atproto.repo.getRecord. 49 + """ 50 + def get_record(did, collection, rkey) do 51 + with {:ok, pds_url} <- resolve_pds_url(did) do 52 + params = URI.encode_query(%{ 53 + "repo" => did, 54 + "collection" => collection, 55 + "rkey" => rkey 56 + }) 57 + 58 + url = "#{pds_url}/xrpc/com.atproto.repo.getRecord?#{params}" 59 + 60 + case Tesla.get(url) do 61 + {:ok, %Tesla.Env{status: 200, body: body}} -> 62 + record = if is_binary(body), do: Jason.decode!(body), else: body 63 + {:ok, record} 64 + 65 + {:ok, %Tesla.Env{status: status}} -> 66 + {:error, {:get_record_failed, status}} 67 + 68 + {:error, reason} -> 69 + {:error, {:get_record_failed, reason}} 70 + end 71 + end 72 + end 73 + 74 + defp extract_pds_endpoint(%{"service" => services}) when is_list(services) do 75 + case Enum.find(services, fn s -> s["id"] == "#atproto_pds" end) do 76 + %{"serviceEndpoint" => endpoint} -> {:ok, endpoint} 77 + _ -> {:error, :no_pds_service} 78 + end 79 + end 80 + 81 + defp extract_pds_endpoint(_), do: {:error, :no_pds_service} 82 + end
+2 -1
appview/lib/atvouch/membership.ex
··· 8 8 schema "memberships" do 9 9 field(:source_did, :string) 10 10 field(:repo_did, :string) 11 + field(:repo_name, :string) 11 12 field(:remote_created_at, :string) 12 13 field(:received_at, :string) 13 14 ··· 16 17 17 18 def changeset(membership, attrs) do 18 19 membership 19 - |> cast(attrs, [:repo_at_uri, :source_did, :repo_did, :remote_created_at, :received_at]) 20 + |> cast(attrs, [:repo_at_uri, :source_did, :repo_did, :repo_name, :remote_created_at, :received_at]) 20 21 |> validate_required([:repo_at_uri, :source_did, :repo_did, :remote_created_at, :received_at]) 21 22 |> unique_constraint(:repo_at_uri, name: :memberships_pkey) 22 23 end
+3 -3
appview/lib/atvouch/tangled/client.ex
··· 11 11 Uses the session cookies from `Atvouch.Tangled.Session` for authentication. 12 12 If a 401 is returned, invalidates the session and retries once. 13 13 """ 14 - def post_comment(repo_handle, repo_rkey, pull_number, body, opts \\ []) do 14 + def post_comment(repo_handle, repo_name, pull_number, body, opts \\ []) do 15 15 session = Keyword.get(opts, :session, Atvouch.Tangled.Session) 16 16 retry = Keyword.get(opts, :retry, true) 17 17 tangled_url = Atvouch.Tangled.Session.get_url(session) 18 18 19 19 case Atvouch.Tangled.Session.get_cookies(session) do 20 20 {:ok, cookies} -> 21 - url = "#{tangled_url}/#{repo_handle}/#{repo_rkey}/pulls/#{pull_number}/round/0/comment" 21 + url = "#{tangled_url}/#{repo_handle}/#{repo_name}/pulls/#{pull_number}/round/0/comment" 22 22 form_body = URI.encode_query(%{"body" => body}) 23 23 24 24 headers = [ ··· 34 34 {:ok, 401} when retry -> 35 35 Logger.info("Tangled returned 401, invalidating session and retrying") 36 36 Atvouch.Tangled.Session.invalidate(session) 37 - post_comment(repo_handle, repo_rkey, pull_number, body, Keyword.put(opts, :retry, false)) 37 + post_comment(repo_handle, repo_name, pull_number, body, Keyword.put(opts, :retry, false)) 38 38 39 39 {:ok, status} -> 40 40 {:error, {:http_error, status}}
+2 -2
appview/lib/atvouch/tangled/scraper.ex
··· 76 76 Returns `{:ok, pulls}` where pulls is the result of `parse_pulls_page/1`, 77 77 or `{:error, reason}`. 78 78 """ 79 - def fetch_pulls(tangled_url, repo_handle, repo_rkey) do 80 - url = "#{tangled_url}/#{repo_handle}/#{repo_rkey}/pulls" 79 + def fetch_pulls(tangled_url, repo_handle, repo_name) do 80 + url = "#{tangled_url}/#{repo_handle}/#{repo_name}/pulls" 81 81 82 82 case Tesla.get(url) do 83 83 {:ok, %Tesla.Env{status: 200, body: body}} ->
+71 -33
appview/lib/atvouch/tap_handler.ex
··· 167 167 defp create_membership(event) do 168 168 repo_at_uri = event.record["repo"] 169 169 repo_did = extract_did_from_at_uri(repo_at_uri) 170 + repo_rkey = extract_rkey_from_at_uri(repo_at_uri) 170 171 maintainers = event.record["maintainers"] || [] 171 172 now = DateTime.utc_now() |> DateTime.to_iso8601() 172 173 173 - case Atvouch.Membership.create( 174 - %{ 175 - repo_at_uri: repo_at_uri, 176 - source_did: event.did, 177 - repo_did: repo_did, 178 - remote_created_at: now, 179 - received_at: now 180 - }, 181 - maintainers 182 - ) do 183 - {:ok, _membership} -> :ok 184 - {:error, reason} -> {:error, reason} 174 + case resolve_repo_name(repo_did, repo_rkey) do 175 + {:ok, repo_name} -> 176 + case Atvouch.Membership.create( 177 + %{ 178 + repo_at_uri: repo_at_uri, 179 + source_did: event.did, 180 + repo_did: repo_did, 181 + repo_name: repo_name, 182 + remote_created_at: now, 183 + received_at: now 184 + }, 185 + maintainers 186 + ) do 187 + {:ok, _membership} -> :ok 188 + {:error, reason} -> {:error, reason} 189 + end 190 + 191 + {:error, reason} -> 192 + Logger.warning("Skipping membership for #{repo_at_uri}: repo record not found (#{inspect(reason)})") 193 + :skip 185 194 end 186 195 end 187 196 ··· 225 234 226 235 defp validate_rkey_matches_repo(_rkey, _repo), do: {:error, "repo field is missing"} 227 236 237 + defp resolve_repo_name(repo_did, repo_rkey, retries \\ 3) do 238 + atproto = Application.get_env(:atvouch, :atproto_module, Atvouch.Atproto) 239 + 240 + case atproto.get_record(repo_did, "sh.tangled.repo", repo_rkey) do 241 + {:ok, %{"value" => %{"name" => name}}} when is_binary(name) -> 242 + {:ok, name} 243 + 244 + {:ok, _} -> 245 + {:error, :missing_name_field} 246 + 247 + # 404 from getRecord means the repo record definitively doesn't exist 248 + {:error, {:get_record_failed, 404}} -> 249 + {:error, :repo_not_found} 250 + 251 + {:error, reason} when retries > 0 -> 252 + Logger.debug("Retrying repo name resolution for #{repo_did}/#{repo_rkey} (#{retries} left): #{inspect(reason)}") 253 + Process.sleep(500) 254 + resolve_repo_name(repo_did, repo_rkey, retries - 1) 255 + 256 + {:error, reason} -> 257 + {:error, reason} 258 + end 259 + end 260 + 228 261 defp extract_did_from_at_uri("at://" <> rest) do 229 262 rest |> String.split("/") |> List.first() 230 263 end ··· 249 282 250 283 defp process_pull_for_membership(membership, author_did) do 251 284 repo_did = membership.repo_did 252 - repo_rkey = extract_rkey_from_at_uri(membership.repo_at_uri) 285 + repo_name = membership.repo_name 253 286 254 - # Resolve repo owner handle for Tangled URL construction 255 - case Atvouch.Identity.one(repo_did) do 256 - nil -> 257 - Logger.warning("Cannot resolve handle for repo DID #{repo_did}, skipping pull") 258 - :ok 287 + if is_nil(repo_name) do 288 + Logger.warning("No repo name for membership #{membership.repo_at_uri}, skipping pull") 289 + :ok 290 + else 291 + # Resolve repo owner handle for Tangled URL construction 292 + case Atvouch.Identity.one(repo_did) do 293 + nil -> 294 + Logger.warning("Cannot resolve handle for repo DID #{repo_did}, skipping pull") 295 + :ok 259 296 260 - %{handle: nil} -> 261 - Logger.warning("No handle for repo DID #{repo_did}, skipping pull") 262 - :ok 297 + %{handle: nil} -> 298 + Logger.warning("No handle for repo DID #{repo_did}, skipping pull") 299 + :ok 263 300 264 - identity -> 265 - process_pull_with_identity(membership, identity.handle, repo_rkey, author_did) 301 + identity -> 302 + process_pull_with_identity(membership, identity.handle, repo_name, author_did) 303 + end 266 304 end 267 305 end 268 306 269 - defp process_pull_with_identity(membership, repo_handle, repo_rkey, author_did) do 307 + defp process_pull_with_identity(membership, repo_handle, repo_name, author_did) do 270 308 tangled_url = Atvouch.Tangled.Session.get_url() 271 309 272 - case Atvouch.Tangled.Scraper.fetch_pulls(tangled_url, repo_handle, repo_rkey) do 310 + case Atvouch.Tangled.Scraper.fetch_pulls(tangled_url, repo_handle, repo_name) do 273 311 {:ok, pulls} -> 274 - process_new_pulls(membership, repo_handle, repo_rkey, pulls, author_did) 312 + process_new_pulls(membership, repo_handle, repo_name, pulls, author_did) 275 313 276 314 {:error, reason} -> 277 - Logger.warning("Failed to fetch pulls for #{repo_handle}/#{repo_rkey}: #{inspect(reason)}") 315 + Logger.warning("Failed to fetch pulls for #{repo_handle}/#{repo_name}: #{inspect(reason)}") 278 316 :ok 279 317 end 280 318 end 281 319 282 - defp process_new_pulls(membership, repo_handle, repo_rkey, pulls, author_did) do 320 + defp process_new_pulls(membership, repo_handle, repo_name, pulls, author_did) do 283 321 maintainer_dids = Atvouch.Membership.maintainers(membership.repo_at_uri) 284 322 membership_received_at = membership.received_at 285 323 ··· 296 334 comment_on_pull( 297 335 membership.repo_at_uri, 298 336 repo_handle, 299 - repo_rkey, 337 + repo_name, 300 338 pull_number, 301 339 author_did, 302 340 maintainer_dids ··· 322 360 # If the scraper couldn't extract a timestamp, skip to be safe 323 361 defp pull_newer_than_membership?(_pull_created_at, _membership_received_at), do: false 324 362 325 - defp comment_on_pull(repo_at_uri, repo_handle, repo_rkey, pull_number, author_did, maintainer_dids) do 363 + defp comment_on_pull(repo_at_uri, repo_handle, repo_name, pull_number, author_did, maintainer_dids) do 326 364 # Resolve author handle 327 365 author_handle = 328 366 case Atvouch.Identity.one(author_did) do ··· 346 384 347 385 comment = Atvouch.Tangled.CommentBuilder.build_comment(author_did, author_handle, maintainer_routes) 348 386 349 - case Atvouch.Tangled.Client.post_comment(repo_handle, repo_rkey, pull_number, comment) do 387 + case Atvouch.Tangled.Client.post_comment(repo_handle, repo_name, pull_number, comment) do 350 388 :ok -> 351 389 now = DateTime.utc_now() |> DateTime.to_iso8601() 352 390 ··· 357 395 commented_at: now 358 396 }) 359 397 360 - Logger.info("Posted vouch comment on #{repo_handle}/#{repo_rkey}##{pull_number}") 398 + Logger.info("Posted vouch comment on #{repo_handle}/#{repo_name}##{pull_number}") 361 399 :ok 362 400 363 401 {:error, reason} -> 364 - Logger.warning("Failed to post comment on #{repo_handle}/#{repo_rkey}##{pull_number}: #{inspect(reason)}") 402 + Logger.warning("Failed to post comment on #{repo_handle}/#{repo_name}##{pull_number}: #{inspect(reason)}") 365 403 :ok 366 404 end 367 405 end
+1
appview/priv/repo/migrations/20260317000000_add_memberships.exs
··· 6 6 add(:repo_at_uri, :string, primary_key: true) 7 7 add(:source_did, :string, null: false) 8 8 add(:repo_did, :string, null: false) 9 + add(:repo_name, :string) 9 10 add(:remote_created_at, :string, null: false) 10 11 add(:received_at, :string, null: false) 11 12 end
+12 -11
appview/test/atvouch/pull_handler_test.exs
··· 126 126 repo_at_uri: repo_at_uri, 127 127 source_did: "did:plc:repoowner", 128 128 repo_did: "did:plc:repoowner", 129 + repo_name: "testrepo", 129 130 remote_created_at: "2026-03-01T00:00:00Z", 130 131 received_at: "2026-03-01T00:00:00Z" 131 132 }, ··· 135 136 repo_at_uri 136 137 end 137 138 138 - defp pulls_html(handle, rkey, opts \\ []) do 139 + defp pulls_html(handle, repo_name, opts \\ []) do 139 140 datetime = Keyword.get(opts, :datetime, "2026-03-19T10:00:00+00:00") 140 141 141 142 """ ··· 144 145 <div class="rounded bg-white dark:bg-gray-800"> 145 146 <div class="px-6 py-4"> 146 147 <div class="pb-2"> 147 - <a href="/#{handle}/#{rkey}/pulls/1" class="dark:text-white"> 148 + <a href="/#{handle}/#{repo_name}/pulls/1" class="dark:text-white"> 148 149 Fix tests <span class="text-gray-500">#1</span> 149 150 </a> 150 151 </div> ··· 168 169 Atvouch.Test.FakeTangledServer.set_pulls_html( 169 170 state_agent, 170 171 "repoowner.test", 171 - "3abc123", 172 - pulls_html("repoowner.test", "3abc123") 172 + "testrepo", 173 + pulls_html("repoowner.test", "testrepo") 173 174 ) 174 175 175 176 # Start TAP socket ··· 213 214 # Verify comment was posted to Tangled 214 215 assert_receive {:tangled_comment, comment}, 10_000 215 216 assert comment.handle == "repoowner.test" 216 - assert comment.rkey == "3abc123" 217 + assert comment.rkey == "testrepo" 217 218 assert comment.number == 1 218 219 assert comment.body =~ "atvouch routes for @author.test" 219 220 assert comment.body =~ "@maintainer1.test" ··· 274 275 Atvouch.Test.FakeTangledServer.set_pulls_html( 275 276 state_agent, 276 277 "repoowner.test", 277 - "3abc123", 278 - pulls_html("repoowner.test", "3abc123", datetime: "2026-02-15T10:00:00+00:00") 278 + "testrepo", 279 + pulls_html("repoowner.test", "testrepo", datetime: "2026-02-15T10:00:00+00:00") 279 280 ) 280 281 281 282 {:ok, _pid} = ··· 328 329 Atvouch.Test.FakeTangledServer.set_pulls_html( 329 330 state_agent, 330 331 "repoowner.test", 331 - "3abc123", 332 - pulls_html("repoowner.test", "3abc123", datetime: "2026-03-01T00:00:00+00:00") 332 + "testrepo", 333 + pulls_html("repoowner.test", "testrepo", datetime: "2026-03-01T00:00:00+00:00") 333 334 ) 334 335 335 336 {:ok, _pid} = ··· 380 381 Atvouch.Test.FakeTangledServer.set_pulls_html( 381 382 state_agent, 382 383 "repoowner.test", 383 - "3abc123", 384 - pulls_html("repoowner.test", "3abc123") 384 + "testrepo", 385 + pulls_html("repoowner.test", "testrepo") 385 386 ) 386 387 387 388 {:ok, _pid} =
+69
appview/test/atvouch/tap_handler_test.exs
··· 8 8 Process.register(self(), :tap_handler_test) 9 9 {server_pid, port} = Atvouch.Test.FakeTapServer.start(self()) 10 10 11 + # Use fake atproto module for repo record resolution 12 + prev_atproto = Application.get_env(:atvouch, :atproto_module) 13 + Application.put_env(:atvouch, :atproto_module, Atvouch.Test.FakeAtproto) 14 + {:ok, _} = Atvouch.Test.FakeAtproto.start() 15 + 11 16 on_exit(fn -> 17 + if prev_atproto do 18 + Application.put_env(:atvouch, :atproto_module, prev_atproto) 19 + else 20 + Application.delete_env(:atvouch, :atproto_module) 21 + end 22 + 12 23 try do 13 24 Supervisor.stop(server_pid, :normal, 1_000) 14 25 catch ··· 442 453 maintainer1 = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa" 443 454 maintainer2 = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbbb" 444 455 456 + # Register the repo record so resolution succeeds 457 + Atvouch.Test.FakeAtproto.set_record(repo_did, "sh.tangled.repo", repo_rkey, %{ 458 + "value" => %{"name" => "my-cool-repo", "knot" => "knot.test", "createdAt" => "2026-03-01T00:00:00Z"} 459 + }) 460 + 445 461 Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 446 462 "id" => 600, 447 463 "type" => "record", ··· 468 484 assert membership.repo_at_uri == repo_at_uri 469 485 assert membership.source_did == source_did 470 486 assert membership.repo_did == repo_did 487 + assert membership.repo_name == "my-cool-repo" 471 488 assert membership.remote_created_at != nil 472 489 assert membership.received_at != nil 473 490 ··· 493 510 maintainer2 = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbbb" 494 511 maintainer3 = "did:plc:ccccccccccccccccccccccccc" 495 512 513 + Atvouch.Test.FakeAtproto.set_record("did:plc:wamidydbgu3u6fk3yckaglnz", "sh.tangled.repo", repo_rkey, %{ 514 + "value" => %{"name" => "update-test-repo", "knot" => "knot.test", "createdAt" => "2026-03-01T00:00:00Z"} 515 + }) 516 + 496 517 # Create 497 518 Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 498 519 "id" => 610, ··· 555 576 repo_rkey = "3mgmqjki6sz2n" 556 577 repo_at_uri = "at://did:plc:wamidydbgu3u6fk3yckaglnz/sh.tangled.repo/#{repo_rkey}" 557 578 maintainer1 = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa" 579 + 580 + Atvouch.Test.FakeAtproto.set_record("did:plc:wamidydbgu3u6fk3yckaglnz", "sh.tangled.repo", repo_rkey, %{ 581 + "value" => %{"name" => "delete-test-repo", "knot" => "knot.test", "createdAt" => "2026-03-01T00:00:00Z"} 582 + }) 558 583 559 584 # Create 560 585 Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ ··· 669 694 670 695 assert_receive {:ws_message, %{"type" => "ack", "id" => 640}}, 5_000 671 696 # No membership should be created due to lexicon validation failure 697 + end 698 + 699 + test "rejects membership when referenced repo record does not exist", %{port: port} do 700 + {:ok, _pid} = 701 + Atvouch.Tap.Socket.start_link( 702 + uri: "ws://localhost:#{port}/channel", 703 + handler: Atvouch.TapHandler, 704 + password: "123", 705 + name: :"tap_handler_membership_no_repo_test_#{port}" 706 + ) 707 + 708 + assert_receive {:ws_connected, ws_pid}, 5_000 709 + 710 + source_did = "did:plc:cpzv5kdrtinsnj5rsblsttz6" 711 + repo_did = "did:plc:wamidydbgu3u6fk3yckaglnz" 712 + repo_rkey = "3nonexistent123" 713 + repo_at_uri = "at://#{repo_did}/sh.tangled.repo/#{repo_rkey}" 714 + 715 + # Do NOT register a fake record — the repo doesn't exist 716 + # FakeAtproto returns 404 by default for unknown records 717 + 718 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 719 + "id" => 650, 720 + "type" => "record", 721 + "record" => %{ 722 + "action" => "create", 723 + "did" => source_did, 724 + "rev" => "3mgmqjki6sz2n", 725 + "collection" => "dev.atvouch.bot.membership", 726 + "rkey" => repo_rkey, 727 + "record" => %{ 728 + "$type" => "dev.atvouch.bot.membership", 729 + "repo" => repo_at_uri, 730 + "maintainers" => ["did:plc:aaaaaaaaaaaaaaaaaaaaaaaaa"] 731 + }, 732 + "cid" => "bafyreig2uvo5annaomcmrjfrwyshnjannfrr2xje5txnsyuk7l4o2tdmju", 733 + "live" => false 734 + } 735 + }) 736 + 737 + assert_receive {:ws_message, %{"type" => "ack", "id" => 650}}, 5_000 738 + 739 + # No membership should be created — the repo record doesn't exist 740 + assert Atvouch.Membership.one(repo_at_uri) == nil 672 741 end 673 742 674 743 test "updates a vouch timestamp from a tap record update event", %{port: port} do
+39
appview/test/support/fake_atproto.ex
··· 1 + defmodule Atvouch.Test.FakeAtproto do 2 + @moduledoc """ 3 + Fake AT Protocol client for testing. Returns configurable responses 4 + for get_record calls via a named agent. 5 + """ 6 + 7 + @agent_name __MODULE__.Agent 8 + 9 + def start do 10 + case Agent.start_link(fn -> %{} end, name: @agent_name) do 11 + {:ok, pid} -> {:ok, pid} 12 + {:error, {:already_started, pid}} -> 13 + Agent.update(pid, fn _ -> %{} end) 14 + {:ok, pid} 15 + end 16 + end 17 + 18 + def set_record(did, collection, rkey, record) do 19 + Agent.update(@agent_name, fn state -> 20 + Map.put(state, {did, collection, rkey}, {:ok, record}) 21 + end) 22 + end 23 + 24 + def set_error(did, collection, rkey, error) do 25 + Agent.update(@agent_name, fn state -> 26 + Map.put(state, {did, collection, rkey}, {:error, error}) 27 + end) 28 + end 29 + 30 + # Called by TapHandler via Application.get_env(:atvouch, :atproto_module) 31 + def get_record(did, collection, rkey) do 32 + case Agent.get(@agent_name, fn state -> Map.get(state, {did, collection, rkey}) end) do 33 + nil -> {:error, {:get_record_failed, 404}} 34 + result -> result 35 + end 36 + end 37 + 38 + def resolve_pds_url(_did), do: {:error, :not_implemented} 39 + end
-658
frontend/src/App.css
··· 1 - @font-face { 2 - font-family: 'DM Sans'; 3 - font-weight: 400; 4 - font-style: normal; 5 - font-display: swap; 6 - src: url('/fonts/DM-Sans-400.ttf') format('truetype'); 7 - } 8 - @font-face { 9 - font-family: 'DM Sans'; 10 - font-weight: 500; 11 - font-style: normal; 12 - font-display: swap; 13 - src: url('/fonts/DM-Sans-500.ttf') format('truetype'); 14 - } 15 - @font-face { 16 - font-family: 'DM Sans'; 17 - font-weight: 700; 18 - font-style: normal; 19 - font-display: swap; 20 - src: url('/fonts/DM-Sans-700.ttf') format('truetype'); 21 - } 22 - @font-face { 23 - font-family: 'JetBrains Mono'; 24 - font-weight: 400; 25 - font-style: normal; 26 - font-display: swap; 27 - src: url('/fonts/JetBrains-Mono-400.ttf') format('truetype'); 28 - } 29 - @font-face { 30 - font-family: 'JetBrains Mono'; 31 - font-weight: 700; 32 - font-style: normal; 33 - font-display: swap; 34 - src: url('/fonts/JetBrains-Mono-700.ttf') format('truetype'); 35 - } 36 - 37 - :root { 38 - --bg: #0c0c0c; 39 - --bg-raised: #141412; 40 - --bg-input: #1a1a17; 41 - --border: #2a2a25; 42 - --border-strong: #3a3a33; 43 - --text: #e8e4de; 44 - --text-dim: #8a867e; 45 - --text-faint: #5a5750; 46 - --accent: #ff4f6d; 47 - --accent-dim: #cc3f57; 48 - --accent-bg: #ff4f6d08; 49 - --error: #ff5f56; 50 - --error-bg: #ff5f5608; 51 - --serif: 'Instrument Serif', Georgia, serif; 52 - --sans: 'DM Sans', system-ui, sans-serif; 53 - --mono: 'JetBrains Mono', 'Courier New', monospace; 54 - } 55 - 56 - * { 57 - box-sizing: border-box; 58 - margin: 0; 59 - padding: 0; 60 - } 61 - 62 - ::selection { 63 - background: var(--accent); 64 - color: var(--bg); 65 - } 66 - 67 - body { 68 - font-family: var(--sans); 69 - font-size: 14px; 70 - background: var(--bg); 71 - color: var(--text); 72 - line-height: 1.65; 73 - -webkit-font-smoothing: antialiased; 74 - } 75 - 76 - /* ── layout ── */ 77 - 78 - .container { 79 - max-width: 1200px; 80 - margin: 0 auto; 81 - padding: 3rem 2rem; 82 - } 83 - 84 - h1 { 85 - font-family: var(--sans); 86 - font-size: 3.5rem; 87 - font-weight: 400; 88 - font-style: normal; 89 - color: var(--text); 90 - letter-spacing: -0.03em; 91 - margin-bottom: 2.5rem; 92 - line-height: 1; 93 - } 94 - 95 - h1::after { 96 - content: ''; 97 - display: block; 98 - width: 100%; 99 - height: 2px; 100 - background: var(--accent); 101 - margin-top: 1rem; 102 - } 103 - 104 - h2 { 105 - font-family: var(--sans); 106 - font-size: 1.5rem; 107 - font-weight: 400; 108 - color: var(--text); 109 - letter-spacing: -0.01em; 110 - margin-bottom: 1rem; 111 - line-height: 1.2; 112 - } 113 - 114 - .intro-block { 115 - display: flex; 116 - gap: 2rem; 117 - margin-bottom: 2rem; 118 - } 119 - 120 - .intro { 121 - color: var(--text); 122 - font-size: 14px; 123 - line-height: 1.7; 124 - flex: 1; 125 - padding-right: 2rem; 126 - border-right: 1px solid var(--border); 127 - } 128 - 129 - .intro-right { 130 - flex: 1; 131 - display: flex; 132 - flex-direction: column; 133 - gap: 1rem; 134 - padding-left: 2rem; 135 - } 136 - 137 - .instructions { 138 - color: var(--text); 139 - font-size: 14px; 140 - text-align: right; 141 - line-height: 1.7; 142 - } 143 - 144 - .instructions p { 145 - margin-bottom: 0.25rem; 146 - } 147 - 148 - .notes { 149 - color: var(--text-dim); 150 - font-size: 12px; 151 - text-align: right; 152 - line-height: 1.7; 153 - border-top: 1px solid var(--border); 154 - padding-top: 1rem; 155 - } 156 - 157 - .notes p { 158 - margin-bottom: 0.25rem; 159 - } 160 - 161 - a { 162 - color: #ef4444; 163 - text-decoration: none; 164 - } 165 - 166 - a:hover { 167 - text-decoration: underline; 168 - } 169 - 170 - a:visited { 171 - color: #b91c1c; 172 - } 173 - 174 - /* ── topbar ── */ 175 - 176 - .topbar { 177 - display: flex; 178 - align-items: center; 179 - justify-content: space-between; 180 - gap: 1rem; 181 - padding: 0.75rem 0; 182 - border-bottom: 2px solid var(--border); 183 - margin-bottom: 2rem; 184 - } 185 - 186 - .topbar span { 187 - font-size: 15px; 188 - color: var(--text-dim); 189 - letter-spacing: 0.02em; 190 - text-transform: uppercase; 191 - } 192 - 193 - .topbar code { 194 - font-family: var(--mono); 195 - font-size: 15px; 196 - color: var(--accent); 197 - text-transform: none; 198 - } 199 - 200 - /* ── two-column layout ── */ 201 - 202 - .layout { 203 - display: flex; 204 - gap: 2rem; 205 - align-items: flex-start; 206 - } 207 - 208 - .sidebar { 209 - width: 260px; 210 - flex-shrink: 0; 211 - border: 1px solid var(--border); 212 - border-top: 3px solid var(--accent); 213 - padding: 1.25rem; 214 - background: var(--bg-raised); 215 - } 216 - 217 - .main-panel { 218 - flex: 1; 219 - min-width: 0; 220 - } 221 - 222 - /* ── sections ── */ 223 - 224 - section { 225 - margin-bottom: 1.5rem; 226 - padding: 1.25rem; 227 - border: 1px solid var(--border); 228 - border-left: 3px solid var(--border-strong); 229 - background: var(--bg-raised); 230 - transition: border-left-color 0.2s ease; 231 - } 232 - 233 - section:hover { 234 - border-left-color: var(--accent); 235 - } 236 - 237 - section:last-child { 238 - margin-bottom: 0; 239 - } 240 - 241 - /* ── text helpers ── */ 242 - 243 - .muted { 244 - color: var(--text-faint); 245 - font-size: 12px; 246 - letter-spacing: 0.03em; 247 - text-transform: uppercase; 248 - } 249 - 250 - /* ── form fields ── */ 251 - 252 - .field { 253 - display: flex; 254 - gap: 0; 255 - } 256 - 257 - input[type="text"] { 258 - flex: 1; 259 - padding: 0.6rem 0.85rem; 260 - border: 1px solid var(--border); 261 - border-right: none; 262 - background: var(--bg-input); 263 - color: var(--text); 264 - font-family: var(--mono); 265 - font-size: 13px; 266 - transition: border-color 0.15s ease; 267 - } 268 - 269 - input[type="text"]::placeholder { 270 - color: var(--text-dim); 271 - font-style: italic; 272 - font-family: var(--sans); 273 - font-size: 13px; 274 - } 275 - 276 - input[type="text"]:focus { 277 - outline: none; 278 - border-color: var(--accent); 279 - background: var(--bg); 280 - } 281 - 282 - input[type="text"].input-invalid { 283 - border-color: var(--error); 284 - background: var(--error-bg); 285 - } 286 - 287 - .field-hint { 288 - color: var(--error); 289 - font-size: 11px; 290 - margin-top: 0.35rem; 291 - letter-spacing: 0.02em; 292 - } 293 - 294 - button { 295 - padding: 0.6rem 1.2rem; 296 - border: 1px solid var(--border); 297 - background: var(--bg-input); 298 - color: var(--text); 299 - cursor: pointer; 300 - font-family: var(--sans); 301 - font-size: 13px; 302 - font-weight: 700; 303 - letter-spacing: 0.06em; 304 - text-transform: uppercase; 305 - white-space: nowrap; 306 - transition: all 0.15s ease; 307 - } 308 - 309 - button:hover:not(:disabled) { 310 - background: var(--accent); 311 - color: var(--bg); 312 - border-color: var(--accent); 313 - } 314 - 315 - button:disabled { 316 - opacity: 0.35; 317 - cursor: not-allowed; 318 - } 319 - 320 - /* ── topbar logout button ── */ 321 - 322 - .topbar button { 323 - font-size: 11px; 324 - padding: 0.4rem 0.8rem; 325 - border-color: var(--border); 326 - } 327 - 328 - /* ── pre / code output ── */ 329 - 330 - pre { 331 - background: var(--bg); 332 - border: 1px solid var(--border); 333 - border-left: 3px solid var(--text-faint); 334 - padding: 0.85rem 1rem; 335 - overflow-x: auto; 336 - font-family: var(--mono); 337 - font-size: 12px; 338 - margin-top: 0.75rem; 339 - white-space: pre-wrap; 340 - word-break: break-all; 341 - line-height: 1.7; 342 - color: var(--text-dim); 343 - } 344 - 345 - /* ── messages ── */ 346 - 347 - .error { 348 - color: var(--error); 349 - background: var(--error-bg); 350 - border: 1px solid var(--error); 351 - border-left: 3px solid var(--error); 352 - padding: 0.6rem 0.85rem; 353 - margin-top: 0.75rem; 354 - font-size: 12px; 355 - } 356 - 357 - .auth-warning { 358 - color: #b5a13b; 359 - background: #b5a13b18; 360 - border: none; 361 - border-left: 3px solid #b5a13b; 362 - padding: 0.6rem 0.85rem; 363 - margin-top: 0.75rem; 364 - font-size: 12px; 365 - line-height: 1.5; 366 - } 367 - 368 - .auth-warning a { 369 - color: #ef4444; 370 - } 371 - 372 - .auth-warning-details { 373 - margin-top: 0.5rem; 374 - margin-bottom: 0; 375 - margin-left: 0; 376 - border: 1px solid #b5a13b44; 377 - background: #131006; 378 - padding: 0.6rem 0.85rem; 379 - border-radius: 4px; 380 - } 381 - 382 - .auth-warning code { 383 - font-size: 11px; 384 - background: #b5a13b15; 385 - padding: 0.1rem 0.3rem; 386 - border-radius: 2px; 387 - } 388 - 389 - .success { 390 - color: #4caf50; 391 - border-color: #4caf50; 392 - border-left: 3px solid #4caf50; 393 - background: #4caf5008; 394 - } 395 - 396 - /* ── login form ── */ 397 - 398 - form { 399 - margin: 0; 400 - } 401 - 402 - /* ── vouch list ── */ 403 - 404 - .vouch-list { 405 - list-style: none; 406 - margin-top: 0.75rem; 407 - max-height: 60vh; 408 - overflow-y: auto; 409 - } 410 - 411 - .vouch-list li { 412 - display: flex; 413 - justify-content: space-between; 414 - align-items: baseline; 415 - padding: 0.5rem 0; 416 - border-bottom: 1px solid var(--border); 417 - gap: 0.75rem; 418 - } 419 - 420 - .vouch-list li:last-child { 421 - border-bottom: none; 422 - } 423 - 424 - 425 - .vouch-handle { 426 - font-size: 15px; 427 - overflow: hidden; 428 - text-overflow: ellipsis; 429 - white-space: nowrap; 430 - flex: 1; 431 - } 432 - 433 - .vouch-date { 434 - color: var(--text-dim); 435 - font-size: 11px; 436 - flex-shrink: 0; 437 - font-variant-numeric: tabular-nums; 438 - } 439 - 440 - .vouch-delete { 441 - background: none; 442 - border: none; 443 - cursor: pointer; 444 - padding: 0 0.25rem; 445 - font-size: 14px; 446 - opacity: 0.5; 447 - flex-shrink: 0; 448 - } 449 - 450 - .vouch-delete:hover { 451 - opacity: 1; 452 - } 453 - 454 - /* ── right sidebar (remote vouches) ── */ 455 - 456 - .sidebar-right { 457 - width: 240px; 458 - flex-shrink: 0; 459 - border: 1px solid var(--border); 460 - border-top: 3px solid var(--text-dim); 461 - padding: 1.25rem; 462 - background: var(--bg-raised); 463 - } 464 - 465 - .vouch-mutual { 466 - color: var(--text-faint); 467 - font-size: 10px; 468 - letter-spacing: 0.06em; 469 - text-transform: uppercase; 470 - flex-shrink: 0; 471 - } 472 - 473 - 474 - /* ── pagination ── */ 475 - 476 - .vouch-count { 477 - font-weight: 400; 478 - font-size: 0.85em; 479 - color: var(--text-dim); 480 - } 481 - 482 - .load-more { 483 - width: 100%; 484 - margin-top: 0.5rem; 485 - font-size: 11px; 486 - padding: 0.4rem 0.8rem; 487 - } 488 - 489 - /* ── footer ── */ 490 - 491 - .footer { 492 - margin-top: 3rem; 493 - padding-top: 1rem; 494 - border-top: 1px solid var(--border); 495 - color: var(--text-dim); 496 - font-size: 12px; 497 - text-align: center; 498 - } 499 - 500 - .design-link { 501 - text-align: center; 502 - margin-top: 3rem; 503 - font-size: 12px; 504 - letter-spacing: 0.03em; 505 - } 506 - 507 - /* ── design decisions ── */ 508 - 509 - .design-decisions { 510 - margin-top: 1rem; 511 - } 512 - 513 - .design-decisions-list { 514 - border: 1px solid var(--border); 515 - border-top: 3px solid var(--accent); 516 - background: var(--bg-raised); 517 - } 518 - 519 - .design-decision-item { 520 - padding: 1rem 1.25rem; 521 - border-bottom: 1px solid var(--border); 522 - } 523 - 524 - .design-decision-item:last-child { 525 - border-bottom: none; 526 - } 527 - 528 - .design-decision-item h3 { 529 - font-family: var(--sans); 530 - font-size: 14px; 531 - font-weight: 700; 532 - color: var(--text); 533 - margin-bottom: 0.35rem; 534 - } 535 - 536 - .design-decision-item p { 537 - font-size: 13px; 538 - line-height: 1.7; 539 - color: var(--text-dim); 540 - } 541 - 542 - /* ── responsive ── */ 543 - 544 - @media (max-width: 980px) { 545 - .container { 546 - padding: 2rem 1rem; 547 - } 548 - 549 - h1 { 550 - font-size: 2.5rem; 551 - } 552 - 553 - .layout { 554 - flex-direction: column; 555 - } 556 - 557 - .sidebar { 558 - width: 100%; 559 - } 560 - 561 - .sidebar-right { 562 - width: 100%; 563 - } 564 - 565 - .main-panel { 566 - width: 100%; 567 - } 568 - 569 - .intro-block { 570 - flex-direction: column; 571 - } 572 - 573 - .intro { 574 - border-right: none; 575 - border-bottom: 1px solid var(--border); 576 - padding-right: 0; 577 - padding-bottom: 1rem; 578 - } 579 - 580 - .intro-right { 581 - padding-left: 0; 582 - } 583 - 584 - .instructions { 585 - text-align: left; 586 - } 587 - 588 - .design-decisions-list { 589 - width: 100%; 590 - } 591 - } 592 - 593 - /* Typeahead */ 594 - .handle-input-wrapper { 595 - position: relative; 596 - flex: 1; 597 - } 598 - 599 - .handle-input-wrapper input { 600 - width: 100%; 601 - box-sizing: border-box; 602 - } 603 - 604 - .typeahead-dropdown { 605 - position: absolute; 606 - top: 100%; 607 - left: 0; 608 - right: 0; 609 - margin: 0; 610 - padding: 0; 611 - list-style: none; 612 - background: var(--bg); 613 - border: 1px solid var(--border); 614 - border-top: none; 615 - z-index: 10; 616 - max-height: 280px; 617 - overflow-y: auto; 618 - } 619 - 620 - .typeahead-dropdown li { 621 - display: flex; 622 - align-items: center; 623 - gap: 0.5rem; 624 - padding: 0.4rem 0.6rem; 625 - cursor: pointer; 626 - } 627 - 628 - .typeahead-dropdown li.typeahead-active { 629 - background: var(--border); 630 - } 631 - 632 - .typeahead-avatar { 633 - width: 24px; 634 - height: 24px; 635 - border-radius: 50%; 636 - flex-shrink: 0; 637 - } 638 - 639 - .typeahead-info { 640 - display: flex; 641 - flex-direction: column; 642 - min-width: 0; 643 - } 644 - 645 - .typeahead-name { 646 - font-size: 0.85rem; 647 - white-space: nowrap; 648 - overflow: hidden; 649 - text-overflow: ellipsis; 650 - } 651 - 652 - .typeahead-handle { 653 - font-size: 0.75rem; 654 - opacity: 0.6; 655 - white-space: nowrap; 656 - overflow: hidden; 657 - text-overflow: ellipsis; 658 - }
+9
frontend/src/App.tsx
··· 15 15 checkVouchPaths, 16 16 fetchRemoteVouchers, 17 17 resolveDidToHandle, 18 + listTangledRepos, 19 + listBotMemberships, 20 + createBotMembership, 21 + deleteBotMembership, 22 + updateBotMembership, 23 + resolveHandle, 24 + searchActorsTypeahead, 18 25 type CheckResult, 19 26 type VouchEntry, 27 + type TangledRepo, 28 + type BotMembership, 20 29 } from "./api"; 21 30 import { HandleInput } from "./HandleInput"; 22 31
+71
lexicons/sh/tangled/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "name": { 19 + "type": "string", 20 + "description": "name of the repo" 21 + }, 22 + "knot": { 23 + "type": "string", 24 + "description": "knot where the repo was created" 25 + }, 26 + "spindle": { 27 + "type": "string", 28 + "description": "CI runner to send jobs to and receive results from" 29 + }, 30 + "description": { 31 + "type": "string", 32 + "minGraphemes": 1, 33 + "maxGraphemes": 140 34 + }, 35 + "website": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "Any URI related to the repo" 39 + }, 40 + "topics": { 41 + "type": "array", 42 + "description": "Topics related to the repo", 43 + "items": { 44 + "type": "string", 45 + "minLength": 1, 46 + "maxLength": 50 47 + }, 48 + "maxLength": 50 49 + }, 50 + "source": { 51 + "type": "string", 52 + "format": "uri", 53 + "description": "source of the repo" 54 + }, 55 + "labels": { 56 + "type": "array", 57 + "description": "List of labels that this repo subscribes to", 58 + "items": { 59 + "type": "string", 60 + "format": "at-uri" 61 + } 62 + }, 63 + "createdAt": { 64 + "type": "string", 65 + "format": "datetime" 66 + } 67 + } 68 + } 69 + } 70 + } 71 + }
+103
lexicons/sh/tangled/repo/pull.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.pull", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "target", 14 + "title", 15 + "patchBlob", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 22 + }, 23 + "title": { 24 + "type": "string" 25 + }, 26 + "body": { 27 + "type": "string" 28 + }, 29 + "patch": { 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 39 + }, 40 + "source": { 41 + "type": "ref", 42 + "ref": "#source" 43 + }, 44 + "createdAt": { 45 + "type": "string", 46 + "format": "datetime" 47 + }, 48 + "mentions": { 49 + "type": "array", 50 + "items": { 51 + "type": "string", 52 + "format": "did" 53 + } 54 + }, 55 + "references": { 56 + "type": "array", 57 + "items": { 58 + "type": "string", 59 + "format": "at-uri" 60 + } 61 + } 62 + } 63 + } 64 + }, 65 + "target": { 66 + "type": "object", 67 + "required": [ 68 + "repo", 69 + "branch" 70 + ], 71 + "properties": { 72 + "repo": { 73 + "type": "string", 74 + "format": "at-uri" 75 + }, 76 + "branch": { 77 + "type": "string" 78 + } 79 + } 80 + }, 81 + "source": { 82 + "type": "object", 83 + "required": [ 84 + "branch", 85 + "sha" 86 + ], 87 + "properties": { 88 + "branch": { 89 + "type": "string" 90 + }, 91 + "sha": { 92 + "type": "string", 93 + "minLength": 40, 94 + "maxLength": 40 95 + }, 96 + "repo": { 97 + "type": "string", 98 + "format": "at-uri" 99 + } 100 + } 101 + } 102 + } 103 + }