From 4690ce5f3c241b77476f3b46502321a3f90cdc43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20H=C3=B8iby?= Date: Wed, 30 Sep 2026 17:55:07 +0200 Subject: [PATCH 1/2] fix(client): send the feed bbox latitude first get_feed(bbox=...) joined the box as given, (west, south, east, north), but /v1/feed reads minLat,minLng,maxLat,maxLng. The overlay then matched latitudes against the longitudes and the box selected the wrong area, usually none. The argument keeps its documented shape, (west, south, east, north); only the wire order changes to south,west,north,east, the same fix as the TypeScript SDK. A bbox that is not four values now raises ValueError instead of being forwarded as is. Adds offline tests (httpx mock transport, sync and async) for the wire order, list and integer input, `near` winning over `bbox`, and the shape check. Updates the README and the changelog. Co-authored-by: claude-code/anthropic/sonnet-5.5 --- CHANGELOG.md | 11 +++++ README.md | 2 +- overlay_social/client.py | 11 ++++- tests/test_feed_query.py | 89 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 tests/test_feed_query.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff0a88..1d69ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to `overlay-social` (Python). Mirrors `@overlay-social/sdk` (TypeScript) one-to-one. +## Unreleased + +### Fixed +- `get_feed(bbox=...)` sent the box longitude first, but `/v1/feed` reads it + latitude first, so the box selected the wrong area (usually none). The + argument is still `(west, south, east, north)`; it is now sent as + `south,west,north,east`, the same fix as `@overlay-social/sdk`. Callers that + worked around this by passing latitude first should switch to the + documented order. A `bbox` that is not four values raises `ValueError` + instead of being forwarded as is. + ## 0.3.0 — 2026-07-29 ### Added diff --git a/README.md b/README.md index 80df88c..04934e8 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ notifs = overlay.get_notifications("", limit=50) overlay.get_follows("
") # follower/following counts + rows overlay.get_blocks("
") # OUTGOING block/mute list only overlay.get_feed(near={"lat": 59.94, "lng": 10.76}, radius_km=2) # geo -overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box +overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box (west, south, east, north) overlay.verify_root("tm_social-content") # on-chain anchor vs live state-root ``` diff --git a/overlay_social/client.py b/overlay_social/client.py index 456e74a..8416247 100644 --- a/overlay_social/client.py +++ b/overlay_social/client.py @@ -66,7 +66,10 @@ def put(k: str, v: Any) -> None: put("author", params.get("author")) put("order", params.get("order", "desc")) put("before", params.get("before")) - # Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine; bbox=(w,s,e,n). + # Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine, or + # bbox=(west, south, east, north) (longitude first, the GeoJSON order). + # On the wire the overlay reads latitude first: bbox=south,west,north,east. + # `near` wins when both are given. near = params.get("near") if near: lat = near.get("lat") if isinstance(near, dict) else near[0] @@ -74,7 +77,11 @@ def put(k: str, v: Any) -> None: put("near", f"{lat},{lng}") put("radius_km", params.get("radius_km")) elif params.get("bbox"): - put("bbox", ",".join(str(x) for x in params["bbox"])) + try: + west, south, east, north = params["bbox"] + except (TypeError, ValueError): + raise ValueError("bbox must be (west, south, east, north)") from None + put("bbox", f"{south},{west},{north},{east}") return out diff --git a/tests/test_feed_query.py b/tests/test_feed_query.py new file mode 100644 index 0000000..208993e --- /dev/null +++ b/tests/test_feed_query.py @@ -0,0 +1,89 @@ +"""Offline tests for the /v1/feed query the clients send. + +No network: the clients are given an ``httpx`` mock transport that records the +request, so these run everywhere (unlike the live tests in ``test_client.py``). +""" + +from __future__ import annotations + +import httpx +import pytest + +from overlay_social import AsyncOverlayClient, OverlayClient + +_EMPTY_FEED = {"status": "ok", "data": [], "limit": 50, "offset": 0} + + +def _recording_handler(calls: list[httpx.Request]): + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(200, json=_EMPTY_FEED) + + return handler + + +def _sync_client(calls: list[httpx.Request]) -> OverlayClient: + transport = httpx.MockTransport(_recording_handler(calls)) + return OverlayClient(base_url="https://overlay.test", client=httpx.Client(transport=transport)) + + +def _async_client(calls: list[httpx.Request]) -> AsyncOverlayClient: + transport = httpx.MockTransport(_recording_handler(calls)) + return AsyncOverlayClient( + base_url="https://overlay.test", client=httpx.AsyncClient(transport=transport) + ) + + +# Oslo: longitude about 10.7, latitude about 59.9. +# The argument is (west, south, east, north), longitude first (GeoJSON order). +OSLO_BBOX = (10.6, 59.8, 10.9, 60.0) +# The overlay reads minLat,minLng,maxLat,maxLng. +OSLO_WIRE = "59.8,10.6,60.0,10.9" + + +def test_bbox_is_sent_latitude_first_sync() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(bbox=OSLO_BBOX) + assert calls[0].url.params["bbox"] == OSLO_WIRE + + +@pytest.mark.asyncio +async def test_bbox_is_sent_latitude_first_async() -> None: + calls: list[httpx.Request] = [] + async with _async_client(calls) as o: + await o.get_feed(bbox=OSLO_BBOX) + assert calls[0].url.params["bbox"] == OSLO_WIRE + + +def test_bbox_accepts_a_list_and_integers() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(bbox=[10, 59, 11, 60]) + assert calls[0].url.params["bbox"] == "59,10,60,11" + + +def test_near_wins_over_bbox_and_keeps_its_wire_format() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(near={"lat": 59.9, "lng": 10.7}, radius_km=3, bbox=(1, 2, 3, 4)) + params = calls[0].url.params + assert params["near"] == "59.9,10.7" + assert params["radius_km"] == "3" + assert "bbox" not in params + + +def test_no_bbox_param_without_a_geo_filter() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(limit=5) + assert "bbox" not in calls[0].url.params + + +@pytest.mark.parametrize("bad", [(1, 2, 3), (1, 2, 3, 4, 5), 7]) +def test_bbox_with_the_wrong_shape_is_rejected_before_any_request(bad: object) -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + with pytest.raises(ValueError, match="west, south, east, north"): + o.get_feed(bbox=bad) + assert calls == [] From 6f0960ac69d4226ffef08b92c50e049a22a16f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20H=C3=B8iby?= Date: Wed, 30 Sep 2026 17:55:16 +0200 Subject: [PATCH 2/2] docs: describe behaviour instead of pointing at non-public files A module docstring, the SourceHandle docstring and the 0.3.0 changelog entry referred to files and repositories that are not part of this project. Describe the behaviour itself instead: how the Zanaadu user number is derived from the source app's registry transactions, without the external spec's section numbers. Same wording as the TypeScript SDK. No runtime change. Co-authored-by: claude-code/anthropic/sonnet-5.5 --- CHANGELOG.md | 5 +++-- overlay_social/client.py | 2 +- overlay_social/models.py | 8 +++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d69ce8..1d0ff7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,9 @@ All notable changes to `overlay-social` (Python). Mirrors `numbers` when a key owns more than one). Additive: comes alongside `author`, never replaces it, and is absent (not `None`) when the author has no number. `membership_proof: "none"` is explicit that the value is - read from Zanaadu's registry, not verified against their Merkle tree. - See `peck-overlay-schema/ZANAADU_POSTANCHOR_FORMAT.md` §13. + read from Zanaadu's registry, not verified against their Merkle tree. The + number is derived from Zanaadu's registry transactions (a registration + advances a counter; entries can change owner through their marketplace). ## 0.2.0 — 2026-06-12 diff --git a/overlay_social/client.py b/overlay_social/client.py index 8416247..62df513 100644 --- a/overlay_social/client.py +++ b/overlay_social/client.py @@ -6,7 +6,7 @@ REST facade that actually runs today (verified against overlay.peck.to), NOT the BRC-24 ``peck-schema`` lookup (that lookup is a no-op). -Semantics mirror @overlay-social/sdk and peck-web/overlay_client.py: +Semantics mirror @overlay-social/sdk: * ``resolve_identities`` returns ``{}`` on ANY error and omits keys without a canonical ProfileToken, so a feed UI can enrich defensively and NEVER break. * single-item lookups return ``None`` for missing/invalid (404/400/empty). diff --git a/overlay_social/models.py b/overlay_social/models.py index 716134e..b820fae 100644 --- a/overlay_social/models.py +++ b/overlay_social/models.py @@ -35,8 +35,10 @@ class SourceHandle(_SourceHandleRequired, total=False): May appear at ``PeckRow["source_handle"]``. Today the only source is Zanaadu: it sells on-chain "user numbers" (an on-chain collectibles registry), and rows with ``app == "zanaadu"`` carry the holder's number - here. See ``peck-overlay-schema/ZANAADU_POSTANCHOR_FORMAT.md`` §13 for - the full derivation and its proof gap. + here. The number is derived from Zanaadu's own registry transactions: a + registration advances a counter and the new entry takes the counter's + previous value, and an entry can later change owner through Zanaadu's + marketplace, so it is not always readable from a single transaction. ``namespace`` is mandatory and comes first so the value can never be shown bare and mistaken for a peck handle — render it qualified, e.g. @@ -57,7 +59,7 @@ class SourceHandle(_SourceHandleRequired, total=False): - ``numbers`` (all numbers this key owns at the source, ascending) is only present when there is more than one; ``value``/``number`` are then the lowest. Tie-break for "which number is primary" when a key - owns several is NOT proven on-chain (§13.7) — lowest was chosen for + owns several is NOT proven on-chain — lowest was chosen for stability over time, not because it is confirmed canonical. """