From b8dba2550fb72969ea1626ae505ebecb34314422 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Sat, 29 Aug 2026 21:31:48 +0100 Subject: [PATCH] Add support for SpaceURI Adds basic parsing, struct building, and strings. Introduces a few helpers to interact with SpaceURIs. First step towards supporting interacting with permissioned data, aka spaces. --- lib/latch/did.ex | 2 + lib/latch/nsid.ex | 2 + lib/latch/record_key.ex | 2 + lib/latch/space_uri.ex | 193 +++++++++++++++ test/latch/nsid_test.exs | 2 + test/latch/record_key_test.exs | 2 + test/latch/space_uri_test.exs | 413 +++++++++++++++++++++++++++++++++ 7 files changed, 616 insertions(+) create mode 100644 lib/latch/space_uri.ex create mode 100644 test/latch/space_uri_test.exs diff --git a/lib/latch/did.ex b/lib/latch/did.ex index 4e95446..912a13d 100644 --- a/lib/latch/did.ex +++ b/lib/latch/did.ex @@ -25,6 +25,8 @@ defmodule Latch.DID do byte_size(did) <= @max_length and Regex.match?(@syntax, did) and method_valid?(did) end + def valid?(nil), do: false + defp method_valid?("did:web:" <> host), do: Regex.match?(@web_host, host) defp method_valid?(_did), do: true end diff --git a/lib/latch/nsid.ex b/lib/latch/nsid.ex index 9ef3317..6e3005c 100644 --- a/lib/latch/nsid.ex +++ b/lib/latch/nsid.ex @@ -17,6 +17,8 @@ defmodule Latch.NSID do byte_size(nsid) <= 317 and String.match?(nsid, @syntax) end + def valid?(nil), do: nil + @doc """ Takes an NSID like `site.standard.document` and extracts the last part, `document`. """ diff --git a/lib/latch/record_key.ex b/lib/latch/record_key.ex index d7eef49..e593a3f 100644 --- a/lib/latch/record_key.ex +++ b/lib/latch/record_key.ex @@ -19,4 +19,6 @@ defmodule Latch.RecordKey do def valid?(key) when is_binary(key) do key not in [".", ".."] and String.match?(key, @regex) end + + def valid?(nil), do: nil end diff --git a/lib/latch/space_uri.ex b/lib/latch/space_uri.ex new file mode 100644 index 0000000..47bb246 --- /dev/null +++ b/lib/latch/space_uri.ex @@ -0,0 +1,193 @@ +defmodule Latch.SpaceURI do + @moduledoc """ + + ## The anatomy of a space at-uri + + at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k + \__/ \______________________________/ \___/ \________________/ \_____/ \______________________________/ \_________________/ \________/ + | | | | | | | | + scheme space authority marker space type skey author collection record key + (always (DID — the space's (literal (NSID — the (an rkey — (DID — the member (NSID — the (rkey — e.g. + "at") root of access) "space") modality) slug or TID) whose repo holds lexicon type) a TID) + the record) + + https://atproto.com/blog/atproto-spaces-alpha + """ + + alias Latch.DID + alias Latch.NSID + alias Latch.RecordKey + + @enforce_keys [:authority, :type, :skey] + defstruct @enforce_keys ++ [:author, :collection, :rkey] + + @doc """ + Parses space at-uris like at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general. + """ + def parse("at://" <> rest) do + parts = String.split(rest, "/") + + case parts do + [authority, "space", type, skey] -> + with :ok <- check_structure(rest, parts), + :ok <- check_authority(authority), + :ok <- check_type(type), + :ok <- check_skey(skey) do + {:ok, + %__MODULE__{ + authority: authority, + type: type, + skey: skey + }} + end + + [authority, "space", type, skey, author, collection, rkey] -> + with :ok <- check_structure(rest, parts), + :ok <- check_authority(authority), + :ok <- check_type(type), + :ok <- check_skey(skey), + :ok <- check_author(author), + :ok <- check_collection(collection), + :ok <- check_rkey(rkey) do + {:ok, + %__MODULE__{ + authority: authority, + type: type, + skey: skey, + author: author, + collection: collection, + rkey: rkey + }} + end + + _other -> + {:error, :invalid_structure} + end + end + + def parse(_), do: {:error, :invalid_scheme} + + def new(authority, type, skey) do + with :ok <- check_authority(authority), + :ok <- check_type(type), + :ok <- check_skey(skey) do + {:ok, + %__MODULE__{ + authority: authority, + type: type, + skey: skey + }} + end + end + + def new(authority, type, skey, author, collection, rkey) do + with :ok <- check_authority(authority), + :ok <- check_type(type), + :ok <- check_skey(skey), + :ok <- check_author(author), + :ok <- check_collection(collection), + :ok <- check_rkey(rkey) do + {:ok, + %__MODULE__{ + authority: authority, + type: type, + skey: skey, + author: author, + collection: collection, + rkey: rkey + }} + end + end + + def new!(authority, type, skey) do + {:ok, space_uri} = new(authority, type, skey) + space_uri + end + + def new!(authority, type, skey, author, collection, rkey) do + {:ok, space_uri} = new(authority, type, skey, author, collection, rkey) + space_uri + end + + def to_string(%__MODULE__{} = space_uri) do + if record?(space_uri) do + "at://#{space_uri.authority}/space/#{space_uri.type}/#{space_uri.skey}/#{space_uri.author}/#{space_uri.collection}/#{space_uri.rkey}" + else + "at://#{space_uri.authority}/space/#{space_uri.type}/#{space_uri.skey}" + end + end + + def to_ref(%__MODULE__{} = space_uri) do + %__MODULE__{space_uri | author: nil, collection: nil, rkey: nil} + end + + def record?(%__MODULE__{} = space_uri) do + not is_nil(space_uri.author) + end + + def space_uri?("at://" <> rest) do + String.contains?(rest, "/space/") + end + + def space_uri?(_other), do: false + + defp check_structure(rest, parts) do + if String.contains?(rest, ["?", "#"]) or Enum.any?(parts, &(&1 == "")) do + {:error, :invalid_structure} + else + :ok + end + end + + defp check_authority(authority) do + if DID.valid?(authority) do + :ok + else + {:error, :invalid_authority} + end + end + + defp check_author(author) do + if DID.valid?(author) do + :ok + else + {:error, :invalid_author} + end + end + + defp check_type(type) do + if NSID.valid?(type) do + :ok + else + {:error, :invalid_type} + end + end + + defp check_collection(collection) do + if NSID.valid?(collection) do + :ok + else + {:error, :invalid_collection} + end + end + + defp check_skey(skey) do + if RecordKey.valid?(skey) do + :ok + else + {:error, :invalid_skey} + end + end + + defp check_rkey(rkey) do + if RecordKey.valid?(rkey) do + :ok + else + {:error, :invalid_rkey} + end + end +end + +defimpl String.Chars, for: Latch.SpaceURI do + def to_string(at_uri), do: Latch.SpaceURI.to_string(at_uri) +end diff --git a/test/latch/nsid_test.exs b/test/latch/nsid_test.exs index 3da5568..1823842 100644 --- a/test/latch/nsid_test.exs +++ b/test/latch/nsid_test.exs @@ -15,6 +15,8 @@ defmodule Latch.NSIDTest do refute NSID.valid?("com.exa💩ple.thing") refute NSID.valid?("com.example") refute NSID.valid?("com.example.3") + + refute NSID.valid?(nil) end end diff --git a/test/latch/record_key_test.exs b/test/latch/record_key_test.exs index 2752ee3..954115e 100644 --- a/test/latch/record_key_test.exs +++ b/test/latch/record_key_test.exs @@ -25,6 +25,8 @@ defmodule Latch.RecordKeyTest do refute RecordKey.valid?("number(3)") refute RecordKey.valid?("\"quote\"") refute RecordKey.valid?("dHJ1ZQ==") + + refute RecordKey.valid?(nil) end end end diff --git a/test/latch/space_uri_test.exs b/test/latch/space_uri_test.exs new file mode 100644 index 0000000..24344c5 --- /dev/null +++ b/test/latch/space_uri_test.exs @@ -0,0 +1,413 @@ +defmodule Latch.SpaceURITest do + use ExUnit.Case, async: true + + alias Latch.SpaceURI + + describe "parse/1" do + test "handles valid uris" do + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + ) + + # did:web authority; reserved-ish skey + assert {:ok, + %SpaceURI{ + authority: "did:web:example.com", + type: "com.example.bookmarks", + skey: "self" + }} = + SpaceURI.parse("at://did:web:example.com/space/com.example.bookmarks/self") + + # TID skey + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "3k2u5kbfqzf2k" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/3k2u5kbfqzf2k" + ) + + # colon in skey + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "literal:default" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/literal:default" + ) + + # underscore/tilde + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "my_forum~v2" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/my_forum~v2" + ) + + # skey length boundary + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: skey + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/#{String.duplicate("a", 512)}" + ) + + assert String.length(skey) == 512 + + # author ≠ authority — a member's record in someone else's space (the shared-space case) + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k" + ) + + # author == authority — personal data + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.example.bookmarks", + skey: "self", + author: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + collection: "com.example.bookmark", + rkey: "3k2u5kbfqzf2k" + }} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.example.bookmarks/self/did:plc:ewvi7nxzyoun6zhxrhs64oiz/com.example.bookmark/3k2u5kbfqzf2k" + ) + end + + test "returns useful errors" do + # :invalid_scheme + assert {:error, :invalid_scheme} = + SpaceURI.parse( + "https://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + ) + + assert {:error, :invalid_scheme} = + SpaceURI.parse( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + ) + + # :invalid_structure + # public URI, marker absent — the routing case + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3k2u5kbfqzf2k" + ) + + # marker only + assert {:error, :invalid_structure} = + SpaceURI.parse("at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space") + + # missing skey + assert {:error, :invalid_structure} = + SpaceURI.parse("at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum") + + # 5 segments + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr" + ) + + # 6 segments + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread" + ) + + # 8 segments + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k/extra" + ) + + # trailing slash + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/" + ) + + # empty segment + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum//general" + ) + + # marker is lowercase-literal + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/Space/com.atmoboards.forum/general" + ) + + # query + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general?foo=bar" + ) + + # fragment + assert {:error, :invalid_structure} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general#frag" + ) + + # :invalid_authority + # handle — DID-only per proposal + assert {:error, :invalid_authority} = + SpaceURI.parse("at://alice.example.com/space/com.atmoboards.forum/general") + + # :invalid_type + # not an NSID (no dots) + assert {:error, :invalid_type} = + SpaceURI.parse("at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/forum/general") + + # underscore fails NSID grammar + assert {:error, :invalid_type} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.example.space_type/general" + ) + + # :invalid_skey + assert {:error, :invalid_skey} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/." + ) + + assert {:error, :invalid_skey} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/.." + ) + + assert {:error, :invalid_skey} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/#{String.duplicate("a", 513)}" + ) + + # char outside [-._:~A-Za-z0-9] + assert {:error, :invalid_skey} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/key!" + ) + + # :invalid_author + # handle + assert {:error, :invalid_author} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/alice.example.com/com.atmoboards.thread/3k2u5kbfqzf2k" + ) + + # :invalid_collection + # not an NSID + assert {:error, :invalid_collection} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/thread/3k2u5kbfqzf2k" + ) + + # :invalid_rkey + assert {:error, :invalid_rkey} = + SpaceURI.parse( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/." + ) + end + end + + describe "new/3" do + test "builds valid SpaceURIs" do + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + }} = + SpaceURI.new( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "com.atmoboards.forum", + "general" + ) + end + end + + describe "new!/3" do + test "builds valid SpaceURIs" do + assert %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + } = + SpaceURI.new!( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "com.atmoboards.forum", + "general" + ) + end + end + + describe "new/6" do + test "builds valid SpaceURIs" do + assert {:ok, + %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + }} = + SpaceURI.new( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "com.atmoboards.forum", + "general", + "did:plc:bvraa6gajy4tfr3eh2sisdkr", + "com.atmoboards.thread", + "3k2u5kbfqzf2k" + ) + end + end + + describe "new!/6" do + test "builds valid SpaceURIs" do + assert %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + } = + SpaceURI.new!( + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + "com.atmoboards.forum", + "general", + "did:plc:bvraa6gajy4tfr3eh2sisdkr", + "com.atmoboards.thread", + "3k2u5kbfqzf2k" + ) + end + end + + describe "to_string/1" do + test "handles space references" do + assert SpaceURI.to_string(%SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + }) == "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + end + + test "handles space records" do + assert SpaceURI.to_string(%SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + }) == + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k" + end + end + + describe "to_ref/1" do + test "strips record fields" do + assert SpaceURI.to_ref(%SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + }) == %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + } + end + end + + describe "record?/1" do + test "recognizes full records" do + assert SpaceURI.record?(%SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + }) + + refute SpaceURI.record?(%SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + }) + end + end + + describe "space_uri?/1" do + test "detects whether a URI string is a space URI" do + assert SpaceURI.space_uri?( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k" + ) + + assert SpaceURI.space_uri?( + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + ) + + refute SpaceURI.space_uri?("at://did:plc:bvraa6gajy4tfr3eh2sisdkr") + refute SpaceURI.space_uri?("at://retr0.id/app.bsky.feed.post/3k5nobkf2w72g") + + refute SpaceURI.space_uri?("http://localhost:4000") + refute SpaceURI.space_uri?("https://example.com") + end + end + + describe "String.Chars impl" do + test "handles references" do + space_uri = %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general" + } + + assert "#{space_uri}" == + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general" + end + + test "handles records" do + space_uri = %SpaceURI{ + authority: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", + type: "com.atmoboards.forum", + skey: "general", + author: "did:plc:bvraa6gajy4tfr3eh2sisdkr", + collection: "com.atmoboards.thread", + rkey: "3k2u5kbfqzf2k" + } + + assert "#{space_uri}" == + "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/space/com.atmoboards.forum/general/did:plc:bvraa6gajy4tfr3eh2sisdkr/com.atmoboards.thread/3k2u5kbfqzf2k" + end + end +end -- 2.51.2