From e08ceb828c393df05812835615dfd1efda0f599c Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Thu, 16 Jul 2026 21:38:52 +0200 Subject: [PATCH] feat(server): add unauthenticated public shelf/entry handlers --- .../handlers/public_shelf.gleam | 172 +++++++++++ server/src/at_record_server/router.gleam | 5 + server/test/public_shelf_test.gleam | 277 ++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 server/src/at_record_server/handlers/public_shelf.gleam create mode 100644 server/test/public_shelf_test.gleam diff --git a/server/src/at_record_server/handlers/public_shelf.gleam b/server/src/at_record_server/handlers/public_shelf.gleam new file mode 100644 index 0000000..6266fdf --- /dev/null +++ b/server/src/at_record_server/handlers/public_shelf.gleam @@ -0,0 +1,172 @@ +//// Public, unauthenticated reads of another repo's shelf: a crate's records +//// live in its owner's own PDS and are public network data (same as +//// browse's fan-out), so these hit the actor's PDS directly with no +//// session, then fold via the same `crate`/`event_log` machinery the authed +//// shelf handlers use. + +import at_record/gen/catalog/release as catalog_release +import at_record/gen/client as generated_client +import at_record/gen/shelf/entry +import at_record/storage.{type StoredItem, StoredItem} +import at_record_server/context.{type Context, error_json} +import at_record_server/crate +import at_record_server/event_log +import atproto/uri +import gleam/dynamic/decode +import gleam/json +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import wisp.{type Request, type Response} + +pub fn get_public_shelf(req: Request, ctx: Context) -> Response { + case list.key_find(wisp.get_query(req), "actor") { + Error(Nil) -> error_json(400, "missing actor") + Ok(actor) -> do_get_public_shelf(ctx, actor) + } +} + +fn do_get_public_shelf(ctx: Context, actor: String) -> Response { + case resolve_actor(ctx, actor) { + Error(Nil) -> error_json(404, "could not resolve that user") + Ok(#(did, handle, pds)) -> + case fetch_public_entries(ctx, pds, did) { + Error(Nil) -> + error_json(502, "could not load that user's crate from their PDS") + Ok(stored) -> { + // Snapshot-less entries (legacy/purge remnants) would fail the + // frontend decoder, so they never leave the BFF; a sold/dropped + // entry is still shown, since a public profile is meant to read + // like the owner's whole current shelf, not just what's kept. + let entries = + crate.fold(stored) + |> list.filter(fn(e) { e.status != crate.Gone }) + |> list.filter(fn(e) { e.snapshot != None }) + json.object([ + #("did", json.string(did)), + #("handle", json.string(handle)), + #("items", json.array(entries, crate.encode_entry)), + ]) + |> json.to_string + |> wisp.json_response(200) + } + } + } +} + +pub fn get_public_entry(req: Request, ctx: Context) -> Response { + let query = wisp.get_query(req) + case list.key_find(query, "actor"), list.key_find(query, "entryId") { + Ok(actor), Ok(entry_id) -> do_get_public_entry(ctx, actor, entry_id) + _, _ -> error_json(400, "missing actor or entryId") + } +} + +fn do_get_public_entry( + ctx: Context, + actor: String, + entry_id: String, +) -> Response { + case resolve_actor(ctx, actor) { + Error(Nil) -> error_json(404, "could not resolve that user") + Ok(#(did, handle, pds)) -> + case fetch_public_entries(ctx, pds, did) { + Error(Nil) -> + error_json(502, "could not load that user's crate from their PDS") + Ok(stored) -> + case event_log.entry_events(stored, entry_id) { + Error(Nil) -> error_json(404, "unknown entry") + Ok(events) -> + case crate.fold(events) |> list.first { + Error(Nil) -> error_json(404, "unknown entry") + Ok(folded) -> { + let release = resolve_release(ctx, folded) + json.object( + list.flatten([ + [ + #("did", json.string(did)), + #("handle", json.string(handle)), + #("entry", crate.encode_entry(folded)), + ], + case release { + Some(r) -> [ + #("release", crate.encode_release_info(r)), + ] + None -> [] + }, + ]), + ) + |> json.to_string + |> wisp.json_response(200) + } + } + } + } + } +} + +// Best-effort read: no ref, or a fetch miss, just means the response omits it. +fn resolve_release( + ctx: Context, + entry: crate.CrateEntry, +) -> Option(catalog_release.CatalogRelease) { + entry.release + |> option.then(fn(ref) { ctx.catalog.fetch_release(ref.uri) }) + |> option.map(fn(pair) { pair.1 }) +} + +/// Handle or DID -> did/handle/pds, via the same Slingshot-backed identity +/// resolver the oauth login flow uses. Any failure (unknown identifier, +/// resolver unreachable) collapses to `Error(Nil)`: from the caller's side, +/// an unresolvable actor looks the same as a nonexistent one. +fn resolve_actor( + ctx: Context, + actor: String, +) -> Result(#(String, String, String), Nil) { + generated_client.identity_resolve_mini_doc( + ctx.atproto.client, + ctx.atproto.resolver, + generated_client.IdentityResolveMiniDocParams(identifier: actor), + None, + ) + |> result.map(fn(doc) { #(doc.did, doc.handle, doc.pds) }) + |> result.replace_error(Nil) +} + +/// Public `listRecords` against the actor's own PDS, no token: same +/// single-page-only shape as `browse.fetch_user_records`, decoded straight +/// into `StoredItem`s so `event_log.entry_events`/`crate.fold` can fold them +/// exactly like an authed load does. +fn fetch_public_entries( + ctx: Context, + pds: String, + did: String, +) -> Result(List(StoredItem(entry.ShelfEntry)), Nil) { + let params = + generated_client.RepoListRecordsParams( + collection: entry.collection, + cursor: None, + limit: Some(100), + repo: did, + reverse: None, + ) + case + generated_client.repo_list_records(ctx.atproto.client, pds, params, None) + { + Error(_) -> Error(Nil) + Ok(output) -> + output.records + |> list.try_map(fn(row) { + decode.run(row.value, entry.shelf_entry_decoder()) + |> result.map(fn(value) { + StoredItem( + uri: row.uri, + cid: row.cid, + rkey: uri.rkey(row.uri), + value:, + ) + }) + }) + |> result.replace_error(Nil) + } +} diff --git a/server/src/at_record_server/router.gleam b/server/src/at_record_server/router.gleam index 242194d..df3a4cb 100644 --- a/server/src/at_record_server/router.gleam +++ b/server/src/at_record_server/router.gleam @@ -8,6 +8,7 @@ import at_record_server/handlers/cover_proxy import at_record_server/handlers/discogs import at_record_server/handlers/edit_inbox import at_record_server/handlers/oauth +import at_record_server/handlers/public_shelf import at_record_server/handlers/shelf import at_record_server/oauth/client_metadata import at_record_server/oauth/keys @@ -71,6 +72,10 @@ fn dispatch_xrpc( amend.amend_entry(req, ctx) "dev.mokkenstorm.crate.shelf.uploadCover", Post -> amend.upload_cover(req, ctx) + "dev.mokkenstorm.crate.shelf.getPublicShelf", Get -> + public_shelf.get_public_shelf(req, ctx) + "dev.mokkenstorm.crate.shelf.getPublicEntry", Get -> + public_shelf.get_public_entry(req, ctx) "dev.mokkenstorm.crate.discogs.searchReleases", Get -> discogs.search(req, ctx) "dev.mokkenstorm.crate.discogs.searchArtists", Get -> diff --git a/server/test/public_shelf_test.gleam b/server/test/public_shelf_test.gleam new file mode 100644 index 0000000..071d84a --- /dev/null +++ b/server/test/public_shelf_test.gleam @@ -0,0 +1,277 @@ +//// Handler tests for the unauthenticated public shelf reads: a fake client +//// branches on the request host, resolveMiniDoc for identity resolution +//// against the resolver, plain listRecords against the actor's own PDS (no +//// token). Mirrors how `cover_proxy_test`/`discogs_scan_test` fake network +//// calls without a real PDS. + +import at_record/gen/catalog/release as catalog_release +import at_record_server/catalog_deps.{type Deps, Deps} +import at_record_server/context.{type Context} +import at_record_server/handlers/public_shelf +import atproto/xrpc +import gleam/bit_array +import gleam/http +import gleam/http/response +import gleam/option.{Some} +import support +import wisp/simulate + +const actor_did = "did:plc:pub" + +const actor_handle = "pub.test" + +const pds_host = "pds.pub.test" + +fn resolve_body() -> String { + "{\"did\":\"" + <> actor_did + <> "\",\"handle\":\"" + <> actor_handle + <> "\",\"pds\":\"https://" + <> pds_host + <> "\",\"signing_key\":\"zTest\"}" +} + +fn owned_genesis(rkey: String) -> String { + "{\"uri\":\"at://" + <> actor_did + <> "/dev.mokkenstorm.crate.shelf.entry/" + <> rkey + <> "\",\"cid\":\"bafy" + <> rkey + <> "\",\"value\":{\"action\":\"acquired\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"snapshot\":{\"title\":\"Spiderland\",\"artistDisplay\":\"Slint\"},\"release\":{\"cid\":\"bafyrel\",\"uri\":\"at://" + <> actor_did + <> "/dev.mokkenstorm.crate.catalog.release/r1\"}}}" +} + +fn wanted_genesis(rkey: String) -> String { + "{\"uri\":\"at://" + <> actor_did + <> "/dev.mokkenstorm.crate.shelf.entry/" + <> rkey + <> "\",\"cid\":\"bafy" + <> rkey + <> "\",\"value\":{\"action\":\"wanted\",\"createdAt\":\"2026-01-02T00:00:00Z\",\"snapshot\":{\"title\":\"Loveless\",\"artistDisplay\":\"My Bloody Valentine\"}}}" +} + +/// A genesis plus a later `sold` event referencing it, so folding leaves it +/// `Gone`: `getPublicShelf` must exclude it from the response. +fn sold_pair(genesis_rkey: String, event_rkey: String) -> #(String, String) { + let genesis_uri = + "at://" + <> actor_did + <> "/dev.mokkenstorm.crate.shelf.entry/" + <> genesis_rkey + let genesis = + "{\"uri\":\"" + <> genesis_uri + <> "\",\"cid\":\"bafy" + <> genesis_rkey + <> "\",\"value\":{\"action\":\"acquired\",\"createdAt\":\"2026-01-03T00:00:00Z\",\"snapshot\":{\"title\":\"Isn't Anything\",\"artistDisplay\":\"My Bloody Valentine\"}}}" + let sold = + "{\"uri\":\"at://" + <> actor_did + <> "/dev.mokkenstorm.crate.shelf.entry/" + <> event_rkey + <> "\",\"cid\":\"bafy" + <> event_rkey + <> "\",\"value\":{\"action\":\"sold\",\"createdAt\":\"2026-01-04T00:00:00Z\",\"subject\":{\"cid\":\"bafy" + <> genesis_rkey + <> "\",\"uri\":\"" + <> genesis_uri + <> "\"}}}" + #(genesis, sold) +} + +fn list_records_body(records: List(String)) -> String { + "{\"records\":[" + <> { + case records { + [] -> "" + _ -> + records + |> fold_join + } + } + <> "]}" +} + +fn fold_join(items: List(String)) -> String { + case items { + [] -> "" + [only] -> only + [first, ..rest] -> first <> "," <> fold_join(rest) + } +} + +fn network_client(records_body: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.host { + "resolver.test" -> + Ok(response.Response(200, [], bit_array.from_string(resolve_body()))) + host if host == pds_host -> + Ok(response.Response(200, [], bit_array.from_string(records_body))) + _ -> panic as "unexpected host" + } + }) +} + +fn failing_resolver_client() -> xrpc.Client { + xrpc.Client(send: fn(_req) { Error("resolver unreachable") }) +} + +fn pds_unreachable_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.host { + "resolver.test" -> + Ok(response.Response(200, [], bit_array.from_string(resolve_body()))) + _ -> Error("pds unreachable") + } + }) +} + +fn a_release() -> catalog_release.CatalogRelease { + catalog_release.CatalogRelease( + ..support.blank_catalog_release(), + title: "Spiderland", + genres: Some(["Rock"]), + country: Some("US"), + released: Some("1991"), + ) +} + +fn catalog_with_release() -> Deps { + Deps(..support.unreachable_catalog_deps(), fetch_release: fn(_uri) { + Some(#("bafyrel", a_release())) + }) +} + +fn test_context(client: xrpc.Client, catalog: Deps) -> Context { + support.stub_context_with( + support.stub_config(client), + catalog, + fn(_req) { Error("unused") }, + [], + ) +} + +pub fn get_public_shelf_folds_and_excludes_gone_entries_test() { + let #(sold_genesis, sold_event) = sold_pair("3ccc", "3ccd") + let records = + list_records_body([ + owned_genesis("3aaa"), + wanted_genesis("3bbb"), + sold_genesis, + sold_event, + ]) + let ctx = + test_context(network_client(records), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_shelf( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=pub.test", + ), + ctx, + ) + assert resp.status == 200 + let body = simulate.read_body(resp) + let assert Ok(handle) = support.field_string(body, ["handle"]) + assert handle == actor_handle + let assert Ok(did) = support.field_string(body, ["did"]) + assert did == actor_did + let assert Ok(titles) = + support.field_nested(body, ["items"], ["snapshot", "title"]) + assert titles == ["Spiderland", "Loveless"] +} + +pub fn get_public_shelf_missing_actor_is_400_test() { + let ctx = + test_context(failing_resolver_client(), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_shelf( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf", + ), + ctx, + ) + assert resp.status == 400 +} + +pub fn get_public_shelf_unresolvable_actor_is_404_test() { + let ctx = + test_context(failing_resolver_client(), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_shelf( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=nobody.test", + ), + ctx, + ) + assert resp.status == 404 +} + +pub fn get_public_shelf_pds_unreachable_is_502_test() { + let ctx = + test_context(pds_unreachable_client(), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_shelf( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicShelf?actor=pub.test", + ), + ctx, + ) + assert resp.status == 502 +} + +pub fn get_public_entry_returns_folded_entry_and_release_test() { + let records = list_records_body([owned_genesis("3aaa")]) + let ctx = test_context(network_client(records), catalog_with_release()) + let resp = + public_shelf.get_public_entry( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test&entryId=3aaa", + ), + ctx, + ) + assert resp.status == 200 + let body = simulate.read_body(resp) + let assert Ok(title) = + support.field_string(body, ["entry", "snapshot", "title"]) + assert title == "Spiderland" + let assert Ok(genres) = support.field_strings(body, ["release", "genres"]) + assert genres == ["Rock"] +} + +pub fn get_public_entry_unknown_entry_is_404_test() { + let records = list_records_body([owned_genesis("3aaa")]) + let ctx = + test_context(network_client(records), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_entry( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test&entryId=missing", + ), + ctx, + ) + assert resp.status == 404 +} + +pub fn get_public_entry_missing_params_is_400_test() { + let ctx = + test_context(failing_resolver_client(), support.unreachable_catalog_deps()) + let resp = + public_shelf.get_public_entry( + simulate.request( + http.Get, + "/xrpc/dev.mokkenstorm.crate.shelf.getPublicEntry?actor=pub.test", + ), + ctx, + ) + assert resp.status == 400 +} -- 2.51.2