wiki: add The Seven Traits page

2026-05-29 02:11:48 +00:00
parent 2b434d617a
commit 9dbc8a9c90

264
The-Seven-Traits.md Normal file

@@ -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<Db>` 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<bool>;
/// 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<Option<String>>;
async fn remove_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> Result<()>;
async fn get_followers(&self, local_user_id: Uuid) -> Result<Vec<Follower>>;
async fn get_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: Uuid) -> Result<usize>;
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<Vec<RemoteActor>>;
async fn get_accepted_follower_inboxes(&self, local_user_id: Uuid) -> Result<Vec<String>>;
async fn count_accepted_followers(&self, local_user_id: Uuid) -> Result<usize>;
async fn get_accepted_followers_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<RemoteActor>>;
// 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<Option<String>>;
async fn remove_following(&self, local_user_id: Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> Result<Vec<RemoteActor>>;
async fn get_following_page(&self, local_user_id: Uuid, offset: u32, limit: usize) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: Uuid) -> Result<usize>;
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<Option<String>>;
// Account migration (Move activity)
async fn migrate_follower_actor(&self, old_actor_url: &str, new_actor_url: &str) -> Result<Vec<Uuid>>;
}
```
**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<Option<(String, String)>>;
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<Option<RemoteActor>>;
// Boost (Announce) tracking
async fn add_announce(&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime<Utc>) -> Result<()>;
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}
```
**`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<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
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<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: Uuid, actor_url: &str) -> Result<bool>;
}
```
`BlockedDomain`: `{ domain: String, reason: Option<String>, 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<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
}
```
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<String>` | `name` |
| `bio` | `Option<String>` | `summary` |
| `avatar_url` | `Option<Url>` | `icon.url` |
| `banner_url` | `Option<Url>` | `image.url` |
| `also_known_as` | `Vec<String>` | `alsoKnownAs` — all known aliases for account migration |
| `profile_url` | `Option<Url>` | `url` |
| `featured_url` | `Option<Url>` | `featured` — points to `/users/{id}/featured` |
| `attachment` | `Vec<ApProfileField>` | `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<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Total locally-authored posts across all users. Used by NodeInfo.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
/// 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<Vec<Url>> {
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).