This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shared/models/federation.py
giles 8850a0106a Add federation/ActivityPub models, contracts, and services
Phase 0+1 of ActivityPub integration:
- 6 ORM models (ActorProfile, APActivity, APFollower, APInboxItem, APAnchor, IPFSPin)
- FederationService protocol + SqlFederationService implementation + stub
- 4 DTOs (ActorProfileDTO, APActivityDTO, APFollowerDTO, APAnchorDTO)
- Registry slot for federation service
- Alembic migration for federation tables
- IPFS async client (httpx-based)
- HTTP Signatures (RSA-2048 sign/verify)
- login_url() now uses AUTH_APP env var for flexible auth routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:10:08 +00:00

196 lines
8.2 KiB
Python

"""Federation / ActivityPub ORM models.
These models support AP identity, activities, followers, inbox processing,
IPFS content addressing, and OpenTimestamps anchoring.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
String, Integer, DateTime, Text, Boolean, BigInteger,
ForeignKey, UniqueConstraint, Index, func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from shared.db.base import Base
class ActorProfile(Base):
"""AP identity for a user. Created when user chooses a username."""
__tablename__ = "ap_actor_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"),
unique=True, nullable=False,
)
preferred_username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
public_key_pem: Mapped[str] = mapped_column(Text, nullable=False)
private_key_pem: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# Relationships
user = relationship("User", backref="actor_profile", uselist=False, lazy="selectin")
activities = relationship("APActivity", back_populates="actor_profile", lazy="dynamic")
followers = relationship("APFollower", back_populates="actor_profile", lazy="dynamic")
__table_args__ = (
Index("ix_ap_actor_user_id", "user_id", unique=True),
Index("ix_ap_actor_username", "preferred_username", unique=True),
)
def __repr__(self) -> str:
return f"<ActorProfile {self.id} @{self.preferred_username}>"
class APActivity(Base):
"""An ActivityPub activity (local or remote)."""
__tablename__ = "ap_activities"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
activity_id: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
activity_type: Mapped[str] = mapped_column(String(64), nullable=False)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
object_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
object_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
signature: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
# Link back to originating domain object (e.g. source_type='post', source_id=42)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# IPFS content-addressed copy of the activity
ipfs_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Anchoring (filled later when batched into a merkle tree)
anchor_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ap_anchors.id", ondelete="SET NULL"), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# Relationships
actor_profile = relationship("ActorProfile", back_populates="activities")
__table_args__ = (
Index("ix_ap_activity_actor", "actor_profile_id"),
Index("ix_ap_activity_source", "source_type", "source_id"),
Index("ix_ap_activity_published", "published"),
)
def __repr__(self) -> str:
return f"<APActivity {self.id} {self.activity_type}>"
class APFollower(Base):
"""A remote follower of a local actor."""
__tablename__ = "ap_followers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
follower_acct: Mapped[str] = mapped_column(String(512), nullable=False)
follower_inbox: Mapped[str] = mapped_column(String(512), nullable=False)
follower_actor_url: Mapped[str] = mapped_column(String(512), nullable=False)
follower_public_key: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
# Relationships
actor_profile = relationship("ActorProfile", back_populates="followers")
__table_args__ = (
UniqueConstraint("actor_profile_id", "follower_acct", name="uq_follower_acct"),
Index("ix_ap_follower_actor", "actor_profile_id"),
)
def __repr__(self) -> str:
return f"<APFollower {self.id} {self.follower_acct}>"
class APInboxItem(Base):
"""Raw incoming AP activity, stored for async processing."""
__tablename__ = "ap_inbox_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor_profile_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ap_actor_profiles.id", ondelete="CASCADE"), nullable=False,
)
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
activity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
from_actor: Mapped[str | None] = mapped_column(String(512), nullable=True)
state: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending", server_default="pending",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_ap_inbox_state", "state"),
Index("ix_ap_inbox_actor", "actor_profile_id"),
)
def __repr__(self) -> str:
return f"<APInboxItem {self.id} {self.activity_type} [{self.state}]>"
class APAnchor(Base):
"""OpenTimestamps anchoring batch — merkle tree of activities."""
__tablename__ = "ap_anchors"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
merkle_root: Mapped[str] = mapped_column(String(128), nullable=False)
tree_ipfs_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
ots_proof_cid: Mapped[str | None] = mapped_column(String(128), nullable=True)
activity_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
bitcoin_txid: Mapped[str | None] = mapped_column(String(128), nullable=True)
def __repr__(self) -> str:
return f"<APAnchor {self.id} activities={self.activity_count}>"
class IPFSPin(Base):
"""Tracks content stored on IPFS — used by all domains."""
__tablename__ = "ipfs_pins"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
ipfs_cid: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
pin_type: Mapped[str] = mapped_column(String(64), nullable=False)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
__table_args__ = (
Index("ix_ipfs_pin_source", "source_type", "source_id"),
Index("ix_ipfs_pin_cid", "ipfs_cid", unique=True),
)
def __repr__(self) -> str:
return f"<IPFSPin {self.id} {self.ipfs_cid[:16]}...>"