Fix per-app AP delivery, NULL uniqueness, and reverse discovery

- Delivery handler now signs/delivers using the per-app domain that
  matches the follower's subscription (not always federation domain)
- app_domain is NOT NULL with default 'federation' (sentinel replaces
  NULL to avoid uniqueness constraint edge case)
- Aggregate actor advertises per-app actors via alsoKnownAs
- Migration backfills existing NULL rows to 'federation'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 19:25:24 +00:00
parent f2262f702b
commit 1bb19c96ed
9 changed files with 117 additions and 80 deletions

View File

@@ -339,7 +339,7 @@ class SqlFederationService:
self, session: AsyncSession, username: str,
follower_acct: str, follower_inbox: str, follower_actor_url: str,
follower_public_key: str | None = None,
app_domain: str | None = None,
app_domain: str = "federation",
) -> APFollowerDTO:
actor = (
await session.execute(
@@ -350,16 +350,15 @@ class SqlFederationService:
raise ValueError(f"Actor not found: {username}")
# Upsert: update if already following this (actor, acct, app_domain)
q = select(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
)
if app_domain is not None:
q = q.where(APFollower.app_domain == app_domain)
else:
q = q.where(APFollower.app_domain.is_(None))
existing = (await session.execute(q)).scalar_one_or_none()
existing = (
await session.execute(
select(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
APFollower.app_domain == app_domain,
)
)
).scalar_one_or_none()
if existing:
existing.follower_inbox = follower_inbox
@@ -382,7 +381,7 @@ class SqlFederationService:
async def remove_follower(
self, session: AsyncSession, username: str, follower_acct: str,
app_domain: str | None = None,
app_domain: str = "federation",
) -> bool:
actor = (
await session.execute(
@@ -392,16 +391,13 @@ class SqlFederationService:
if actor is None:
return False
filters = [
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
]
if app_domain is not None:
filters.append(APFollower.app_domain == app_domain)
else:
filters.append(APFollower.app_domain.is_(None))
result = await session.execute(delete(APFollower).where(*filters))
result = await session.execute(
delete(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
APFollower.app_domain == app_domain,
)
)
return result.rowcount > 0
async def get_followers_paginated(

View File

@@ -235,10 +235,10 @@ class StubFederationService:
async def add_follower(self, session, username, follower_acct, follower_inbox,
follower_actor_url, follower_public_key=None,
app_domain=None):
app_domain="federation"):
raise RuntimeError("FederationService not available")
async def remove_follower(self, session, username, follower_acct, app_domain=None):
async def remove_follower(self, session, username, follower_acct, app_domain="federation"):
return False
async def get_or_fetch_remote_actor(self, session, actor_url):