feat: add ephemeral friend presence and invites

Add short-lived capability channels for online friend status and public-room invitations without storing accounts, fingerprints, social graphs, or offline messages.
This commit is contained in:
Alexander Sellite 2026-08-23 20:52:57 -04:00
parent 029e247649
commit 21d17c4211
7 changed files with 626 additions and 3 deletions

View file

@ -8,6 +8,10 @@ The service also runs a small UDP rendezvous. It observes packets sent from the
used by hosts and joiners, then lets an authenticated room host retrieve pending endpoints and send
hole-punch packets. Gameplay remains direct and never passes through this service.
Friend presence and live room invitations use the same service as short-lived capability channels.
There are no discovery accounts, durable friend records, or offline messages; friendship state
stays on each player's device.
## Requirements
- Python 3.11 or newer

View file

@ -77,6 +77,25 @@ UDP rendezvous from the same ENet socket used for the gameplay connection.
Requires the room lease bearer token. It consumes observed joining endpoints so the host can send
same-socket UDP punch packets before ENet retries its connection.
## Live friend presence and invitations
These endpoints are an ephemeral capability channel, not an account or social-graph service. The
server does not receive player identity fingerprints or a complete friend list, writes nothing to
a database, and discards presence and invitations after short timeouts.
`POST /v1/presence` publishes or removes the caller's status under one or more secret write
capabilities. `POST /v1/presence/query` reads the corresponding hashed channels. A published room
is returned only while it remains a verified, version-compatible public room.
`POST /v1/invitations/poll` marks the supplied capability inboxes as currently reachable and
consumes any live invitations. `POST /v1/invitations` sends a public-room invitation to one inbox.
The send fails with `409 friend_offline` and `This person needs to be online to do this.` unless
that inbox was polled recently. There is no offline delivery.
All four requests include the normal `game_version` and `protocol_version`. Capability values are
64 lowercase hexadecimal characters and must be exchanged by the games during an authenticated,
live NETfishing session.
## Error shape
```json
@ -94,4 +113,6 @@ same-socket UDP punch packets before ENet retries its connection.
- `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured
trusted proxy CIDR.
- Lease tokens authorize update and deletion but are never included in public listings.
- The service is an ephemeral directory, not a source of gameplay authority.
- Friend capability channels are short-lived and reveal neither identity fingerprints nor a full
social graph to the service.
- The service is an ephemeral directory, not a source of gameplay or friendship authority.

View file

@ -224,6 +224,26 @@ class RoomRegistry:
self._purge_locked(now)
return len(self._rooms)
def get_public_room(
self,
room_id: str,
*,
game_version: str | None = None,
protocol_version: int | None = None,
) -> RoomAdvertisement | None:
now = self._clock()
with self._lock:
self._purge_locked(now)
lease = self._rooms.get(room_id)
room = lease.advertisement if lease is not None else None
if room is None or not room.verified:
return None
if game_version is not None and room.game_version != game_version:
return None
if protocol_version is not None and room.protocol_version != protocol_version:
return None
return room
def verify_endpoint(
self,
room_id: str,

View file

@ -27,10 +27,11 @@ from .registry import (
RoomRegistry,
ValidationError,
)
from .social_registry import FriendOfflineError, SocialRegistry
LOGGER = logging.getLogger("netfishing.discovery")
DEFAULT_MAX_BODY_BYTES = 8 * 1024
DEFAULT_MAX_BODY_BYTES = 64 * 1024
TRAVERSAL_PACKET_PREFIX = b"NETFISHING_TRAVERSAL_V1 "
MAX_TRAVERSAL_PACKET_BYTES = 2048
@ -94,13 +95,19 @@ class DiscoveryHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, config: ServerConfig, registry: RoomRegistry | None = None) -> None:
def __init__(
self,
config: ServerConfig,
registry: RoomRegistry | None = None,
social_registry: SocialRegistry | None = None,
) -> None:
self.config = config
self.registry = registry or RoomRegistry(
config.room_ttl_seconds,
config.max_rooms,
config.max_rooms_per_address,
)
self.social_registry = social_registry or SocialRegistry()
super().__init__((config.bind_host, config.bind_port), DiscoveryRequestHandler)
@ -111,6 +118,7 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
route = urlsplit(self.path)
if route.path == "/health":
presence_count, invitation_count = self.server.social_registry.counts()
self._send_json(
HTTPStatus.OK,
{
@ -119,6 +127,8 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
"version": __version__,
"build_revision": self.server.config.build_revision,
"active_rooms": self.server.registry.room_count(),
"active_presence_channels": presence_count,
"pending_invitations": invitation_count,
},
)
return
@ -163,6 +173,14 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
path = urlsplit(self.path).path
if path in [
"/v1/presence",
"/v1/presence/query",
"/v1/invitations",
"/v1/invitations/poll",
]:
self._handle_social_post(path)
return
room_id = self._room_id_for_suffix(path, "/join-attempts")
if room_id is not None:
try:
@ -205,6 +223,63 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
},
)
def _handle_social_post(self, path: str) -> None:
payload = self._read_json_object()
if payload is None or self._reject_incompatible_game_version(payload):
return
try:
game_version = payload.get("game_version")
protocol_version = payload.get("protocol_version")
if not isinstance(game_version, str):
raise ValidationError("game_version must be a string")
if (
isinstance(protocol_version, bool)
or not isinstance(protocol_version, int)
or protocol_version < 1
):
raise ValidationError("protocol_version must be a positive integer")
if path == "/v1/presence":
channels = self.server.social_registry.publish_presence(
self._client_address(), payload
)
self._send_json(HTTPStatus.OK, {"channels": channels})
return
if path == "/v1/presence/query":
presence = self.server.social_registry.query_presence(
payload.get("channels"),
game_version.strip(),
protocol_version,
self.server.registry,
)
self._send_json(HTTPStatus.OK, {"presence": presence})
return
if path == "/v1/invitations/poll":
invitations = self.server.social_registry.poll_invitations(
payload.get("inbox_tokens"),
game_version.strip(),
protocol_version,
self.server.registry,
)
self._send_json(HTTPStatus.OK, {"invitations": invitations})
return
invitation = self.server.social_registry.send_invitation(
payload.get("inbox_token"),
payload.get("room_id", ""),
game_version.strip(),
protocol_version,
self.server.registry,
)
self._send_json(
HTTPStatus.CREATED,
{"invite_id": invitation.invite_id},
)
except FriendOfflineError as error:
self._send_error(HTTPStatus.CONFLICT, "friend_offline", str(error))
except RoomNotFoundError as error:
self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error))
except ValidationError as error:
self._send_error(HTTPStatus.BAD_REQUEST, "invalid_social_request", str(error))
def do_PUT(self) -> None:
room_id = self._room_id_from_path()
if room_id is None:

View file

@ -0,0 +1,277 @@
"""Ephemeral, capability-addressed friend presence and live invitations."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import secrets
import threading
import time
import unicodedata
from collections.abc import Callable, Mapping
from typing import Any
from .registry import RoomAdvertisement, RoomNotFoundError, RoomRegistry, ValidationError
PRESENCE_DOMAIN = "NETFISHING_PRESENCE_V1:"
INVITE_DOMAIN = "NETFISHING_INVITE_V1:"
SOCIAL_TOKEN_LENGTH = 64
MAX_SOCIAL_CHANNELS_PER_REQUEST = 200
MAX_SOCIAL_RECORDS = 8192
MAX_SOCIAL_RECORDS_PER_ADDRESS = 512
MAX_INVITES_PER_INBOX = 8
PRESENCE_TTL_SECONDS = 45.0
INBOX_REACHABLE_SECONDS = 15.0
INVITE_TTL_SECONDS = 30.0
class FriendOfflineError(Exception):
pass
@dataclass(frozen=True, slots=True)
class Presence:
channel: str
display_name: str
room_id: str
game_version: str
protocol_version: int
address: str
expires_at: float
@dataclass(frozen=True, slots=True)
class Invitation:
invite_id: str
inbox_id: str
room_id: str
expires_at: float
def _capability_id(domain: str, token: str) -> str:
return hashlib.sha256(f"{domain}{token}".encode("utf-8")).hexdigest()
def _clean_token(value: Any, field_name: str) -> str:
if not isinstance(value, str) or len(value) != SOCIAL_TOKEN_LENGTH:
raise ValidationError(f"{field_name} must be a 64-character capability")
if any(character not in "0123456789abcdef" for character in value):
raise ValidationError(f"{field_name} must contain lowercase hexadecimal characters")
return value
def _clean_token_list(value: Any, field_name: str) -> list[str]:
if not isinstance(value, list) or len(value) > MAX_SOCIAL_CHANNELS_PER_REQUEST:
raise ValidationError(
f"{field_name} must contain at most {MAX_SOCIAL_CHANNELS_PER_REQUEST} entries"
)
result: list[str] = []
for raw_token in value:
token = _clean_token(raw_token, field_name)
if token not in result:
result.append(token)
return result
def _clean_display_name(value: Any) -> str:
if not isinstance(value, str):
raise ValidationError("display_name must be a string")
result = unicodedata.normalize("NFC", value).strip()
if not result or len(result) > 24:
raise ValidationError("display_name must contain between 1 and 24 characters")
if any(unicodedata.category(character).startswith("C") for character in result):
raise ValidationError("display_name contains unsupported control characters")
return result
def _clean_protocol(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValidationError("protocol_version must be a positive integer")
return value
class SocialRegistry:
"""Stores no accounts and no durable data; every record expires in seconds."""
def __init__(self, clock: Callable[[], float] = time.time) -> None:
self._clock = clock
self._presence: dict[str, Presence] = {}
self._inbox_polls: dict[str, float] = {}
self._invites: dict[str, list[Invitation]] = {}
self._lock = threading.RLock()
def publish_presence(
self, address: str, payload: Mapping[str, Any]
) -> list[str]:
write_tokens = _clean_token_list(payload.get("write_tokens"), "write_tokens")
online = payload.get("online")
if not isinstance(online, bool):
raise ValidationError("online must be a boolean")
channels = [_capability_id(PRESENCE_DOMAIN, token) for token in write_tokens]
now = self._clock()
with self._lock:
self._purge_locked(now)
if not online:
for channel in channels:
current = self._presence.get(channel)
if current is not None and current.address == address:
del self._presence[channel]
return channels
display_name = _clean_display_name(payload.get("display_name"))
game_version = payload.get("game_version")
if not isinstance(game_version, str) or not game_version.strip():
raise ValidationError("game_version must be a non-empty string")
protocol_version = _clean_protocol(payload.get("protocol_version"))
room_id = payload.get("room_id", "")
if not isinstance(room_id, str) or len(room_id) > 64 or "/" in room_id:
raise ValidationError("room_id is invalid")
new_channels = [channel for channel in channels if channel not in self._presence]
address_count = sum(
1 for record in self._presence.values() if record.address == address
)
if len(self._presence) + len(new_channels) > MAX_SOCIAL_RECORDS:
raise ValidationError("the live presence directory is at capacity")
if address_count + len(new_channels) > MAX_SOCIAL_RECORDS_PER_ADDRESS:
raise ValidationError("too many live presence channels from this address")
for channel in channels:
self._presence[channel] = Presence(
channel=channel,
display_name=display_name,
room_id=room_id,
game_version=game_version.strip(),
protocol_version=protocol_version,
address=address,
expires_at=now + PRESENCE_TTL_SECONDS,
)
return channels
def query_presence(
self,
channels_value: Any,
game_version: str,
protocol_version: int,
rooms: RoomRegistry,
) -> list[dict[str, Any]]:
channels = _clean_token_list(channels_value, "channels")
now = self._clock()
result: list[dict[str, Any]] = []
with self._lock:
self._purge_locked(now)
records = [self._presence.get(channel) for channel in channels]
for record in records:
if (
record is None
or record.game_version != game_version
or record.protocol_version != protocol_version
):
continue
room = rooms.get_public_room(
record.room_id,
game_version=game_version,
protocol_version=protocol_version,
) if record.room_id else None
result.append(
{
"channel": record.channel,
"display_name": record.display_name,
"online": True,
"room": room.public_dict() if room is not None else {},
}
)
return result
def poll_invitations(
self,
inbox_tokens_value: Any,
game_version: str,
protocol_version: int,
rooms: RoomRegistry,
) -> list[dict[str, Any]]:
tokens = _clean_token_list(inbox_tokens_value, "inbox_tokens")
inbox_ids = [_capability_id(INVITE_DOMAIN, token) for token in tokens]
now = self._clock()
invitations: list[Invitation] = []
with self._lock:
self._purge_locked(now)
for inbox_id in inbox_ids:
self._inbox_polls[inbox_id] = now
invitations.extend(self._invites.pop(inbox_id, []))
result: list[dict[str, Any]] = []
for invitation in invitations:
room = rooms.get_public_room(
invitation.room_id,
game_version=game_version,
protocol_version=protocol_version,
)
if room is None:
continue
result.append(
{
"invite_id": invitation.invite_id,
"inbox_id": invitation.inbox_id,
"room": room.public_dict(),
}
)
return result
def send_invitation(
self,
inbox_token_value: Any,
room_id: str,
game_version: str,
protocol_version: int,
rooms: RoomRegistry,
) -> Invitation:
inbox_token = _clean_token(inbox_token_value, "inbox_token")
if not isinstance(room_id, str) or not room_id or len(room_id) > 64:
raise ValidationError("room_id is invalid")
room = rooms.get_public_room(
room_id,
game_version=game_version,
protocol_version=protocol_version,
)
if room is None:
raise RoomNotFoundError("room is not available")
inbox_id = _capability_id(INVITE_DOMAIN, inbox_token)
now = self._clock()
with self._lock:
self._purge_locked(now)
last_poll = self._inbox_polls.get(inbox_id, 0.0)
if now - last_poll > INBOX_REACHABLE_SECONDS:
raise FriendOfflineError("This person needs to be online to do this.")
queue = self._invites.setdefault(inbox_id, [])
if len(queue) >= MAX_INVITES_PER_INBOX:
queue.pop(0)
invitation = Invitation(
invite_id=secrets.token_hex(16),
inbox_id=inbox_id,
room_id=room.room_id,
expires_at=now + INVITE_TTL_SECONDS,
)
queue.append(invitation)
return invitation
def counts(self) -> tuple[int, int]:
now = self._clock()
with self._lock:
self._purge_locked(now)
return len(self._presence), sum(len(queue) for queue in self._invites.values())
def _purge_locked(self, now: float) -> None:
self._presence = {
channel: record
for channel, record in self._presence.items()
if record.expires_at > now
}
self._inbox_polls = {
inbox_id: polled_at
for inbox_id, polled_at in self._inbox_polls.items()
if now - polled_at <= INBOX_REACHABLE_SECONDS
}
for inbox_id in list(self._invites):
queue = [invite for invite in self._invites[inbox_id] if invite.expires_at > now]
if queue:
self._invites[inbox_id] = queue
else:
del self._invites[inbox_id]

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
from http.client import HTTPConnection
import json
import socketserver
@ -203,6 +204,95 @@ class DiscoveryHTTPTests(unittest.TestCase):
self.assertEqual(body["version"], "0.16.2-alpha")
self.assertEqual(body["build_revision"], "test-build")
def test_live_friend_presence_and_invitation_endpoints(self) -> None:
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
self.assertEqual(status, 201)
assert created is not None
room = created["room"]
traversal = created["traversal"]
assert isinstance(room, dict)
assert isinstance(traversal, dict)
room_id = str(room["room_id"])
self.server.registry.verify_endpoint(
room_id,
str(traversal["verification_token"]),
"127.0.0.1",
7777,
)
inbox_token = "b" * 64
social_version = {
"game_version": VALID_ROOM["game_version"],
"protocol_version": VALID_ROOM["protocol_version"],
}
status, polled = self.request(
"POST",
"/v1/invitations/poll",
dict(social_version, inbox_tokens=[inbox_token]),
)
self.assertEqual(status, 200)
assert polled is not None
self.assertEqual(polled["invitations"], [])
status, _sent = self.request(
"POST",
"/v1/invitations",
dict(social_version, inbox_token=inbox_token, room_id=room_id),
)
self.assertEqual(status, 201)
status, polled = self.request(
"POST",
"/v1/invitations/poll",
dict(social_version, inbox_tokens=[inbox_token]),
)
self.assertEqual(status, 200)
assert polled is not None
self.assertEqual(polled["invitations"][0]["room"]["room_id"], room_id)
status, offline = self.request(
"POST",
"/v1/invitations",
dict(social_version, inbox_token="c" * 64, room_id=room_id),
)
self.assertEqual(status, 409)
assert offline is not None
self.assertEqual(offline["error"]["code"], "friend_offline")
self.assertEqual(
offline["error"]["message"],
"This person needs to be online to do this.",
)
write_token = "d" * 64
channel = hashlib.sha256(
f"NETFISHING_PRESENCE_V1:{write_token}".encode()
).hexdigest()
status, _published = self.request(
"POST",
"/v1/presence",
dict(
social_version,
write_tokens=[write_token],
online=True,
display_name="Voyager",
room_id=room_id,
),
)
self.assertEqual(status, 200)
status, presence = self.request(
"POST",
"/v1/presence/query",
dict(social_version, channels=[channel]),
)
self.assertEqual(status, 200)
assert presence is not None
self.assertEqual(presence["presence"][0]["room"]["room_id"], room_id)
self.request(
"DELETE",
f"/v1/rooms/{room_id}",
token=str(created["lease_token"]),
)
def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None:
self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn))

View file

@ -0,0 +1,136 @@
from __future__ import annotations
import hashlib
import unittest
from netfishing_discovery.registry import RoomRegistry, ValidationError
from netfishing_discovery.social_registry import (
FriendOfflineError,
INBOX_REACHABLE_SECONDS,
PRESENCE_TTL_SECONDS,
SocialRegistry,
)
GAME_VERSION = "0.16.2-alpha"
PROTOCOL_VERSION = 9
VALID_ROOM = {
"room_name": "Pond Friends",
"port": 7777,
"current_players": 1,
"max_players": 8,
"game_version": GAME_VERSION,
"protocol_version": PROTOCOL_VERSION,
}
class FakeClock:
def __init__(self) -> None:
self.now = 1_000.0
def __call__(self) -> float:
return self.now
def capability_id(domain: str, token: str) -> str:
return hashlib.sha256(f"{domain}{token}".encode()).hexdigest()
class SocialRegistryTests(unittest.TestCase):
def setUp(self) -> None:
self.clock = FakeClock()
self.rooms = RoomRegistry(ttl_seconds=300.0, clock=self.clock)
room, _lease, verification = self.rooms.create(
"203.0.113.10", VALID_ROOM
)
self.room = self.rooms.verify_endpoint(
room.room_id, verification, "203.0.113.10", 7777
)
self.social = SocialRegistry(clock=self.clock)
def test_presence_is_capability_addressed_and_expires(self) -> None:
write_token = "a" * 64
channel = capability_id("NETFISHING_PRESENCE_V1:", write_token)
self.social.publish_presence(
"203.0.113.10",
{
"write_tokens": [write_token],
"online": True,
"display_name": "Voyager",
"room_id": self.room.room_id,
"game_version": GAME_VERSION,
"protocol_version": PROTOCOL_VERSION,
},
)
presence = self.social.query_presence(
[channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms
)
self.assertEqual(len(presence), 1)
self.assertEqual(presence[0]["channel"], channel)
self.assertEqual(presence[0]["room"]["room_id"], self.room.room_id)
self.clock.now += PRESENCE_TTL_SECONDS
self.assertEqual(
self.social.query_presence(
[channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms
),
[],
)
def test_invitation_requires_a_current_live_poll(self) -> None:
inbox_token = "b" * 64
with self.assertRaisesRegex(
FriendOfflineError,
"This person needs to be online to do this",
):
self.social.send_invitation(
inbox_token,
self.room.room_id,
GAME_VERSION,
PROTOCOL_VERSION,
self.rooms,
)
self.assertEqual(
self.social.poll_invitations(
[inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms
),
[],
)
sent = self.social.send_invitation(
inbox_token,
self.room.room_id,
GAME_VERSION,
PROTOCOL_VERSION,
self.rooms,
)
received = self.social.poll_invitations(
[inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms
)
self.assertEqual(len(received), 1)
self.assertEqual(received[0]["invite_id"], sent.invite_id)
self.assertEqual(received[0]["room"]["room_id"], self.room.room_id)
self.clock.now += INBOX_REACHABLE_SECONDS + 0.01
with self.assertRaises(FriendOfflineError):
self.social.send_invitation(
inbox_token,
self.room.room_id,
GAME_VERSION,
PROTOCOL_VERSION,
self.rooms,
)
def test_invalid_capabilities_are_rejected(self) -> None:
with self.assertRaises(ValidationError):
self.social.publish_presence(
"203.0.113.10",
{
"write_tokens": ["not-a-capability"],
"online": False,
},
)
if __name__ == "__main__":
unittest.main()