Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ notifs = overlay.get_notifications("<posting-address>", limit=50)
overlay.get_follows("<address>") # follower/following counts + rows
overlay.get_blocks("<address>") # 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
```

Expand Down
13 changes: 10 additions & 3 deletions overlay_social/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -66,15 +66,22 @@ 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]
lng = near.get("lng") if isinstance(near, dict) else near[1]
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


Expand Down
8 changes: 5 additions & 3 deletions overlay_social/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
"""

Expand Down
89 changes: 89 additions & 0 deletions tests/test_feed_query.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading