From 9dbc8a9c90dda7d2b9e6b4063c5341058e508d58 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 29 May 2026 02:11:48 +0000 Subject: [PATCH] wiki: add The Seven Traits page --- The-Seven-Traits.md | 264 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 The-Seven-Traits.md diff --git a/The-Seven-Traits.md b/The-Seven-Traits.md new file mode 100644 index 0000000..e2838c1 --- /dev/null +++ b/The-Seven-Traits.md @@ -0,0 +1,264 @@ +# The Seven Traits + +k-ap calls into your code through seven focused traits. Implement them against your database or storage layer. The simplest approach — used by both `thoughts` and `movies-diary` — is to implement all seven on a single `struct Db` and clone the `Arc` into each builder setter. + +```rust +#[derive(Clone)] +struct Db { pool: PgPool } + +// Implement all seven traits on Db... + +let db = Arc::new(Db { pool }); +let service = ActivityPubService::builder("https://example.com") + .activity_repo(db.clone()) + .follow_repo(db.clone()) + .actor_repo(db.clone()) + .blocklist_repo(db.clone()) + .user_repo(db.clone()) + .content_reader(db.clone()) + .object_handler(db.clone()) + .build() + .await?; +``` + +--- + +## 1. ActivityRepository + +Idempotency for inbound deliveries. Every incoming activity is checked here before any handler is called. + +```rust +#[async_trait] +pub trait ActivityRepository: Send + Sync { + /// Returns true if this activity ID has already been processed. + async fn is_activity_processed(&self, activity_id: &str) -> Result; + + /// Record that this activity ID has been processed. + async fn mark_activity_processed(&self, activity_id: &str) -> Result<()>; +} +``` + +**What to persist:** a table of processed activity ID strings with a unique constraint. `VARCHAR` + unique index is sufficient. + +--- + +## 2. FollowRepository + +The follower/following graph plus account migration. + +```rust +#[async_trait] +pub trait FollowRepository: Send + Sync { + // Inbound followers (remote actor → local user) + async fn add_follower(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>; + async fn get_follower_follow_activity_id(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result>; + async fn remove_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<()>; + async fn get_followers(&self, local_user_id: Uuid) -> Result>; + async fn get_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result>; + async fn count_followers(&self, local_user_id: Uuid) -> Result; + async fn update_follower_status(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowerStatus) -> Result<()>; + async fn get_pending_followers(&self, local_user_id: Uuid) -> Result>; + async fn get_accepted_follower_inboxes(&self, local_user_id: Uuid) -> Result>; + async fn count_accepted_followers(&self, local_user_id: Uuid) -> Result; + async fn get_accepted_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result>; + + // Outbound following (local user → remote actor) + async fn add_following(&self, local_user_id: Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()>; + async fn get_follow_activity_id(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result>; + async fn remove_following(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>; + async fn get_following(&self, local_user_id: Uuid) -> Result>; + async fn get_following_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result>; + async fn count_following(&self, local_user_id: Uuid) -> Result; + async fn update_following_status(&self, local_user_id: Uuid, remote_actor_url: &str, status: FollowingStatus) -> Result<()>; + async fn get_following_outbox_url(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result>; + + // Account migration (Move activity) + async fn migrate_follower_actor(&self, old_actor_url: &str, new_actor_url: &str) -> Result>; +} +``` + +**Key types:** +- `FollowerStatus`: `Pending` | `Accepted` | `Rejected` +- `FollowingStatus`: `Pending` | `Accepted` +- `Follower`: `{ actor: RemoteActor, status: FollowerStatus }` +- `RemoteActor`: `{ url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url }` + +**`get_accepted_follower_inboxes`** must return deduplicated inbox URLs (prefer `shared_inbox_url` over `inbox_url`) for accepted followers only, excluding blocked actors/domains. This is called on every broadcast — DB-side filtering matters for scale. + +**What to persist:** two tables — `followers` (remote → local, with `status` and `follow_activity_id`) and `following` (local → remote, with `status` and `follow_activity_id`). + +--- + +## 3. ActorRepository + +Local keypairs, remote actor cache, and Announce tracking. + +```rust +#[async_trait] +pub trait ActorRepository: Send + Sync { + // Local keypairs + async fn get_local_actor_keypair(&self, user_id: Uuid) -> Result>; + async fn save_local_actor_keypair(&self, user_id: Uuid, public_key: String, private_key: String) -> Result<()>; + + // Remote actor cache + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>; + async fn get_remote_actor(&self, actor_url: &str) -> Result>; + + // Boost (Announce) tracking + async fn add_announce(&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime) -> Result<()>; + async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>; + async fn count_announces(&self, object_url: &str) -> Result; +} +``` + +**`get_local_actor_keypair`** returns `Some((public_key_pem, private_key_pem))` if the keypair exists, `None` if not yet generated. k-ap will call `save_local_actor_keypair` the first time it encounters a user with no keypair. + +**What to persist:** +- Local keypairs: RSA-2048 PEM strings, one pair per local user +- Remote actor cache: `remote_actors` table (URL, inbox, shared_inbox, display_name, avatar_url, outbox_url) +- Announce records: `(activity_id, object_url, actor_url, announced_at)` + +--- + +## 4. BlocklistRepository + +Domain and per-actor blocklists. k-ap checks these before processing inbound activities. + +```rust +#[async_trait] +pub trait BlocklistRepository: Send + Sync { + async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>; + async fn remove_blocked_domain(&self, domain: &str) -> Result<()>; + async fn get_blocked_domains(&self) -> Result>; + async fn is_domain_blocked(&self, domain: &str) -> Result; + + async fn add_blocked_actor(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>; + async fn remove_blocked_actor(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>; + async fn get_blocked_actors(&self, local_user_id: Uuid) -> Result>; + async fn is_actor_blocked(&self, local_user_id: Uuid, actor_url: &str) -> Result; +} +``` + +`BlockedDomain`: `{ domain: String, reason: Option, blocked_at: String }` + +Domain blocks are instance-wide. Actor blocks are per local user. **What to persist:** `blocked_domains` table and `blocked_actors` table. + +--- + +## 5. ApUserRepository + +Local user lookup. k-ap calls this to build actor JSON for outbox, inbox, and WebFinger responses. + +```rust +#[async_trait] +pub trait ApUserRepository: Send + Sync { + async fn find_by_id(&self, id: Uuid) -> anyhow::Result>; + async fn find_by_username(&self, username: &str) -> anyhow::Result>; + async fn count_users(&self) -> anyhow::Result; +} +``` + +Returns `ApUser` — the AP-serializable representation of a local user. All fields control how actor JSON is built: + +| Field | Type | AP field | +|-------|------|----------| +| `id` | `Uuid` | — (used to build the actor URL) | +| `username` | `String` | `preferredUsername` | +| `display_name` | `Option` | `name` | +| `bio` | `Option` | `summary` | +| `avatar_url` | `Option` | `icon.url` | +| `banner_url` | `Option` | `image.url` | +| `also_known_as` | `Vec` | `alsoKnownAs` — all known aliases for account migration | +| `profile_url` | `Option` | `url` | +| `featured_url` | `Option` | `featured` — points to `/users/{id}/featured` | +| `attachment` | `Vec` | `attachment` — PropertyValue metadata fields | +| `manually_approves_followers` | `bool` | `manuallyApprovesFollowers` | +| `discoverable` | `bool` | `discoverable` | +| `actor_type` | `ApActorType` | `type` — Person / Service / Application / Organization / Group | + +--- + +## 6. ApContentReader + +The read side — provides local content to k-ap for outbox pagination and backfill. + +```rust +#[async_trait] +pub trait ApContentReader: Send + Sync { + /// Newest-first page of locally-authored objects for `user_id`, published + /// strictly before `before` (pass None for the first page). + /// Returns (ap_id, object_json, published_at) tuples. + async fn get_local_objects_page( + &self, + user_id: Uuid, + before: Option>, + limit: usize, + ) -> anyhow::Result)>>; + + /// Total locally-authored posts across all users. Used by NodeInfo. + async fn count_local_posts(&self) -> anyhow::Result; + + /// AP URLs of pinned objects for this user, in display order. + /// Served at GET /users/{id}/featured. Default: empty. + async fn get_featured_objects(&self, user_id: Uuid) -> anyhow::Result> { + Ok(vec![]) + } +} +``` + +**`get_local_objects_page`** must return objects in **descending `published_at` order**, exclude deleted and draft content, and be consistent across pages (no duplicates, no gaps). The returned `object_json` is served directly in the outbox — it should be a fully serialized AP object (e.g. a `Note` JSON value). + +**`count_local_posts`** has no `user_id` parameter — it counts all posts across all local users for NodeInfo. + +Override `get_featured_objects` to expose pinned posts. See [Pinned Posts](Pinned-Posts). + +--- + +## 7. ApObjectHandler + +The write side — called when the inbox receives AP activities. + +```rust +#[async_trait] +pub trait ApObjectHandler: Send + Sync { + /// A remote actor published new content. + async fn on_create(&self, ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> anyhow::Result<()>; + + /// A remote actor edited existing content. + async fn on_update(&self, ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> anyhow::Result<()>; + + /// A remote actor deleted an object. + async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// A remote actor was deleted or has unfollowed all local users. + /// Remove all content and state for this actor from local storage. + async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>; + + /// A remote actor liked a locally-authored object. + async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// A remote actor removed their like. + async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// A remote actor boosted a locally-authored object. + async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// A remote actor removed their boost (Undo(Announce)). Default: no-op. + async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> { + Ok(()) + } + + /// A remote actor boosted an object hosted on a different server. + /// Use to surface cross-server boosts in local feeds. + async fn on_announce_of_remote(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>; + + /// A local user was mentioned in an inbound Create or Update. + async fn on_mention(&self, object_ap_id: &Url, mentioned_user_id: Uuid, actor_url: &Url) -> anyhow::Result<()>; +} +``` + +**Returning `Err`** propagates a 500 back to the remote server, which triggers a retry. Return `Ok(())` to silently accept an activity you don't want to act on. + +**Idempotency:** methods may be called more than once for the same activity under duplicate delivery. Prefer upsert over insert. + +One method has a default no-op implementation: `on_announce_removed`. All other methods are required, including `on_announce_of_remote` (its failures are logged and swallowed — they do not fail the activity). \ No newline at end of file