wiki: add The Seven Traits page
264
The-Seven-Traits.md
Normal file
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).
|
||||
Reference in New Issue
Block a user