Add per-app ActivityPub actors via shared AP blueprint

Each AP-enabled app (blog, market, events, federation) now serves its
own webfinger, actor profile, inbox, outbox, and followers endpoints.
Per-app actors are virtual projections of the same ActorProfile/keypair,
scoped by APFollower.app_domain and APActivity.origin_app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 19:02:30 +00:00
parent 001cbffd74
commit f2262f702b
10 changed files with 1087 additions and 35 deletions

View File

@@ -75,6 +75,7 @@ def _follower_to_dto(f: APFollower) -> APFollowerDTO:
follower_inbox=f.follower_inbox,
follower_actor_url=f.follower_actor_url,
created_at=f.created_at,
app_domain=f.app_domain,
)
@@ -252,6 +253,7 @@ class SqlFederationService:
async def get_outbox(
self, session: AsyncSession, username: str,
page: int = 1, per_page: int = 20,
origin_app: str | None = None,
) -> tuple[list[APActivityDTO], int]:
actor = (
await session.execute(
@@ -261,22 +263,23 @@ class SqlFederationService:
if actor is None:
return [], 0
filters = [
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
]
if origin_app is not None:
filters.append(APActivity.origin_app == origin_app)
total = (
await session.execute(
select(func.count(APActivity.id)).where(
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
)
select(func.count(APActivity.id)).where(*filters)
)
).scalar() or 0
offset = (page - 1) * per_page
result = await session.execute(
select(APActivity)
.where(
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
)
.where(*filters)
.order_by(APActivity.published.desc())
.limit(per_page)
.offset(offset)
@@ -315,6 +318,7 @@ class SqlFederationService:
async def get_followers(
self, session: AsyncSession, username: str,
app_domain: str | None = None,
) -> list[APFollowerDTO]:
actor = (
await session.execute(
@@ -324,15 +328,18 @@ class SqlFederationService:
if actor is None:
return []
result = await session.execute(
select(APFollower).where(APFollower.actor_profile_id == actor.id)
)
q = select(APFollower).where(APFollower.actor_profile_id == actor.id)
if app_domain is not None:
q = q.where(APFollower.app_domain == app_domain)
result = await session.execute(q)
return [_follower_to_dto(f) for f in result.scalars().all()]
async def add_follower(
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,
) -> APFollowerDTO:
actor = (
await session.execute(
@@ -342,15 +349,17 @@ class SqlFederationService:
if actor is None:
raise ValueError(f"Actor not found: {username}")
# Upsert: update if already following, insert if new
existing = (
await session.execute(
select(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
)
)
).scalar_one_or_none()
# 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()
if existing:
existing.follower_inbox = follower_inbox
@@ -365,6 +374,7 @@ class SqlFederationService:
follower_inbox=follower_inbox,
follower_actor_url=follower_actor_url,
follower_public_key=follower_public_key,
app_domain=app_domain,
)
session.add(follower)
await session.flush()
@@ -372,6 +382,7 @@ class SqlFederationService:
async def remove_follower(
self, session: AsyncSession, username: str, follower_acct: str,
app_domain: str | None = None,
) -> bool:
actor = (
await session.execute(
@@ -381,12 +392,16 @@ class SqlFederationService:
if actor is None:
return False
result = await session.execute(
delete(APFollower).where(
APFollower.actor_profile_id == actor.id,
APFollower.follower_acct == follower_acct,
)
)
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))
return result.rowcount > 0
async def get_followers_paginated(