This commit is contained in:
2026-05-15 01:25:16 +02:00
parent 0734ef20c6
commit 4cd94b3c7f
24 changed files with 1 additions and 17402 deletions

View File

@@ -1,118 +0,0 @@
# REST API Cleanup Design
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
## Route Changes
| Before | After | Reason |
|--------|-------|--------|
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
## Content Negotiation at `GET /users/{username}`
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
- Anything else → return `UserResponse` with `Content-Type: application/json`
**Implementation:**
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
The unified handler in `presentation/src/handlers/users.rs`:
1. Looks up user by username via `UserRepository` → 404 if not found
2. Checks `Accept` header
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
4. REST path: returns `UserResponse` as before
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
## Unified Follow at `POST /users/{username}/follow`
The handler detects whether `{username}` is a local user or a remote actor:
```rust
if username.contains('@') {
// Remote: e.g. "gabrielkaszewski@mastodon.social"
s.federation.follow_remote(&uid, &username).await?;
} else {
// Local: look up by username, call follow_user use case
let target = get_user_by_username(&*s.users, &username).await?;
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
}
```
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
## Remote Actor Handle Format Fix
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
```rust
let domain = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain);
// RemoteActor { handle: full_handle, ... }
```
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
## Remote Unfollow Scope
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
## Notification Endpoints
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
Both replace their existing `POST` counterparts.
## Frontend (`thoughts-frontend/lib/api.ts`)
| Function | Change |
|----------|--------|
| `getUserProfile(username)` | URL: `/users/${username}/profile``/users/${username}` |
| `getFollowersList(username)` | URL: `/follower-list``/followers` |
| `getFollowingList(username)` | URL: `/following-list``/following` |
| `getMeFollowingList()` | URL: `/me/following-list``/me/following` |
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=``/users/lookup?handle=` |
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
| `markNotificationRead(id)` | **New**`PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
| `markAllNotificationsRead()` | **New**`PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
## Files Touched
**Backend:**
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}``{username}` in follow/block; rename follower/following list handlers
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
- `crates/presentation/src/routes.rs` — all route changes
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
**Frontend:**
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)

View File

@@ -1,300 +0,0 @@
# Remote Actor Profile Design
Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.
## Data Flow
1. User navigates to `/users/@gabrielkaszewski@mastodon.social`
2. Frontend detects `@user@domain` format, calls in parallel:
- `GET /users/lookup?handle=@user@instance` → enriched profile metadata
- `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit)
3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget
4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note`
5. On next visit/refresh posts are populated
## Domain Changes
### Extend `domain/src/models/remote_actor.rs`
Add fields:
```rust
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub public_key: String,
pub avatar_url: Option<String>,
pub last_fetched_at: DateTime<Utc>,
// new:
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<(String, String)>, // (name, value)
}
```
### New `domain/src/models/remote_note.rs`
```rust
pub struct RemoteNote {
pub ap_id: String,
pub content: String,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
}
```
### New `DomainEvent` variant (`domain/src/events.rs`)
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
}
```
### New `FederationActionPort` method (`domain/src/ports.rs`)
```rust
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<RemoteNote>, DomainError>;
```
`TestStore` stub returns `Ok(vec![])`.
## activitypub-base Implementation
### `lookup_actor` — populate new `RemoteActor` fields
Map from `DbActor`:
```rust
bio: actor.bio.clone(),
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as.clone(),
outbox_url: Some(actor.outbox_url.to_string()),
attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(),
```
### `fetch_outbox_page` impl on `ActivityPubService`
```rust
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
let url = format!("{}?page={}", outbox_url, page);
let resp: serde_json::Value = reqwest::Client::new()
.get(&url)
.header("Accept", "application/activity+json, application/ld+json")
.send().await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json().await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
Ok(items.iter().filter_map(|item| {
// Items are Create activities or Notes directly
let note = if item["type"].as_str() == Some("Create") {
&item["object"]
} else if item["type"].as_str() == Some("Note") {
item
} else {
return None;
};
// Only public notes
let to = note["to"].as_array()?;
let is_public = to.iter().any(|t| {
t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
});
if !is_public { return None; }
Some(RemoteNote {
ap_id: note["id"].as_str()?.to_string(),
content: note["content"].as_str().unwrap_or("").to_string(),
published: chrono::DateTime::parse_from_rfc3339(
note["published"].as_str()?
).ok()?.with_timezone(&chrono::Utc),
sensitive: note["sensitive"].as_bool().unwrap_or(false),
content_warning: note["summary"].as_str().map(|s| s.to_string()),
})
}).collect())
}
```
## AppState + Bootstrap
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
Wire in `bootstrap/src/factory.rs`:
```rust
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
```
## event-payload
Add to `EventPayload` enum:
```rust
FetchRemoteActorPosts {
actor_ap_url: String,
outbox_url: String,
}
```
Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test.
## REST Endpoint
**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`):
```rust
pub async fn remote_actor_posts_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let actor = s.federation.lookup_actor(&handle).await?;
let ap_url = url::Url::parse(&actor.url)
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
// Get or create interned local UserId for this remote actor
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
Some(id) => id,
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
};
// Return cached posts
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
// Trigger background fetch (fire and forget)
if let Some(outbox_url) = &actor.outbox_url {
let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
actor_ap_url: actor.url.clone(),
outbox_url: outbox_url.clone(),
}).await;
}
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
```
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
Add `pub mod federation_actors;` to `handlers/mod.rs`.
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
## api-types
Extend `RemoteActorResponse`:
```rust
pub struct RemoteActorResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
// new:
pub bio: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub outbox_url: Option<String>,
pub attachment: Vec<ProfileField>,
}
pub struct ProfileField {
pub name: String,
pub value: String,
}
```
Update `lookup_handler` in `users.rs` to populate all new fields.
## Worker
### `FederationEventService` new deps
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
```rust
DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
Ok(n) => n,
Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); }
};
let actor_url = url::Url::parse(actor_ap_url)
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
for note in notes {
let ap_id = match url::Url::parse(&note.ap_id) {
Ok(u) => u,
Err(_) => continue,
};
// accept_note is idempotent — ignore duplicate errors
let _ = self.ap_repo.accept_note(
&ap_id, &author_id, &note.content, note.published,
note.sensitive, note.content_warning, "public",
).await;
}
Ok(())
}
```
Wire new deps in `worker/src/factory.rs`.
## Frontend
### `lib/api.ts`
```typescript
// Enriched RemoteActorSchema (same endpoint, more fields)
export const ProfileFieldSchema = z.object({
name: z.string(),
value: z.string(),
});
export const RemoteActorSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
avatarUrl: z.string().nullable(),
url: z.string(),
bio: z.string().nullable(),
bannerUrl: z.string().nullable(),
alsoKnownAs: z.string().nullable(),
outboxUrl: z.string().nullable(),
attachment: z.array(ProfileFieldSchema),
});
export const getRemoteActorPosts = (handle: string, page: number, token: string | null) =>
apiFetch(
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
{},
z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }),
token
);
```
### `app/users/[username]/page.tsx`
Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render `<RemoteUserProfile>`. Otherwise: existing local profile.
### New `components/remote-user-profile.tsx`
Client component showing:
- Banner (`bannerUrl`) — full-width image or placeholder
- Avatar + display name + handle (`@user@instance`)
- Bio (rendered as text)
- Profile fields (`attachment`) — key-value table
- "Also known as" link (if present)
- External profile link button → `url` in new tab
- Follow button (reuse `followUser(handle, token)`)
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
- Pagination controls

View File

@@ -1,81 +0,0 @@
# Remote Actor Search & Follow
Allows local users to search for and follow users on other ActivityPub instances (e.g. `@user@mastodon.social`) directly from the existing search page.
## Architecture
Approach A: new `FederationActionPort` domain trait + dedicated `/federation/*` REST endpoints. Keeps hexagonal arch intact — presentation has no dep on `activitypub-base`.
## Domain changes
**`domain/src/models/remote_actor.rs`** — add `avatar_url: Option<String>`
**`domain/src/errors.rs`** — add `ExternalService(String)` variant
**`domain/src/ports.rs`** — new trait:
```rust
pub trait FederationActionPort: Send + Sync {
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
}
```
## activitypub-base impl
`impl domain::ports::FederationActionPort for ActivityPubService` in `service.rs`:
- `lookup_actor`: calls `webfinger_resolve_actor(handle, &data)` → maps `DbActor` to `domain::RemoteActor`
- `follow_remote`: delegates to existing `self.follow(local_user_id.inner(), handle)` (already handles WebFinger + Follow activity + federation DB record)
## Bootstrap refactor
`factory.rs` currently builds `FederationData` + `ApFederationConfig` directly. Switch to `ActivityPubService::new(...)` which creates both internally. `Infrastructure` holds `Arc<ActivityPubService>` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`.
`AppState` gets one new field:
```rust
pub federation: Arc<dyn FederationActionPort>,
```
Wired to `Arc::clone(&ap_service)` in factory.
## REST endpoints
**`api-types/src/responses.rs`** — new:
```rust
pub struct RemoteActorResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
}
```
**`presentation/src/handlers/federation.rs`** (new file):
| Method | Path | Auth | Body | Response |
|--------|------|------|------|----------|
| GET | `/federation/lookup?handle=@user@instance.tld` | none | — | `RemoteActorResponse` |
| POST | `/federation/follow` | bearer | `{"handle":"@user@instance.tld"}` | 204 |
Mounted in `routes.rs` under `/federation`.
Error mapping: `DomainError::ExternalService` → 502, `DomainError::NotFound` → 404.
## Frontend
**`lib/api.ts`**:
- `RemoteActorSchema` + `RemoteActor` type
- `lookupRemoteActor(handle, token)``GET /federation/lookup?handle=...`
- `followRemoteUser(handle, token)``POST /federation/follow`
**`app/search/page.tsx`**:
- Detect `@user@instance.tld` via regex `/^@[\w.-]+@[\w.-]+\.\w+$/`
- If matches: call `lookupRemoteActor` in parallel with local search
- Pass remote actor result to component; show in Users tab above local results
**`components/remote-user-card.tsx`** (new client component):
- Displays avatar, handle, display name
- Follow button calls `followRemoteUser(handle, token)`
- No unfollow needed for MVP (remote following status not tracked locally)

View File

@@ -1,285 +0,0 @@
# Thoughts v2 — Architecture Rewrite Design
## Context
Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting:
- Hexagonal architecture (ports & adapters, zero leakage between layers)
- Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen)
- sqlx with raw SQL — no ORM
- Postgres only (for now), but no coupling to any concrete adapter
- Crate structure mirroring movies-diary (the reference implementation)
- Production data must survive cutover via additive migrations
---
## Crate Structure
```
crates/
domain/ # entities, value objects, ports (traits), domain events
application/ # use cases (commands + queries), no framework deps
api-types/ # request/response DTOs, shared serializable types
presentation/ # axum handlers, routes, extractors, state, openapi — JSON REST only, no HTML rendering (client is Next.js)
worker/ # event consumer loop, dispatches to event handlers
adapters/
postgres/ # sqlx impls of all repos + migrations/
postgres-search/ # SearchPort via pg_trgm / tsvector
postgres-federation/ # federation-specific queries (known actors, etc.)
activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo
activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers
auth/ # JWT AuthService impl
nats/ # EventPublisher + EventConsumer via NATS
event-payload/ # serializable event envelope types (NATS wire format)
event-publisher/ # event routing — domain events → NATS subjects
```
**Dependency rule:** `domain` has zero external deps. `application` depends only on `domain`. All adapters depend on `domain` traits only — never on each other. `presentation` and `worker` wire concrete adapters into `Arc<dyn Port>` and inject via state. `presentation` never imports from `postgres` directly.
---
## Domain Model
### Entities & Value Objects
```
User — UserId, Username, Email, PasswordHash, DisplayName, Bio,
AvatarUrl, HeaderUrl, local: bool, ap_id: Url,
public_key: String, private_key: Option<String> (None for remote)
Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
visibility: Public|Followers|Unlisted|Direct,
content_warning: Option<String>, sensitive: bool, local: bool
Like — LikeId, UserId, ThoughtId, ap_id: Url
Boost — BoostId, UserId, ThoughtId, ap_id: Url
Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url
Block — BlockerId, BlockedId
Tag — TagId, name
ApiKey — ApiKeyId, UserId, key_hash, name
TopFriend — UserId, FriendId, position (18)
RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key
```
### Ports (traits in domain, implemented by adapters)
`UserRepository`, `ThoughtRepository`, `LikeRepository`, `BoostRepository`,
`FollowRepository`, `BlockRepository`, `TagRepository`, `ApiKeyRepository`,
`TopFriendRepository`, `RemoteActorRepository`, `AuthService`, `PasswordHasher`,
`EventPublisher`, `EventConsumer`, `SearchPort`, `SearchCommand`
### Domain Events
Published after mutations, consumed by worker for federation and side-effects:
`ThoughtCreated`, `ThoughtDeleted`, `ThoughtUpdated`,
`LikeAdded`, `LikeRemoved`,
`BoostAdded`, `BoostRemoved`,
`FollowRequested`, `FollowAccepted`, `FollowRejected`, `Unfollowed`,
`UserBlocked`
---
## Application Layer (Use Cases)
Each use case lives in `application/src/use_cases/` and receives only `&dyn Port` references — no framework types, no sqlx, no axum. Fully testable with mock impls.
**Commands** (mutate state, publish domain event):
```
register, login
create_thought, delete_thought, edit_thought
create_reply, delete_reply
like_thought, unlike_thought
boost_thought, unboost_thought
follow_user, unfollow_user, accept_follow, reject_follow
block_user, unblock_user
update_profile, update_top_friends
create_api_key, delete_api_key
handle_inbox ← processes incoming AP activities from remote instances
```
**Queries** (read-only, no events):
```
get_thought, get_thread ← thought + its reply tree
get_home_feed ← thoughts from followed users (local + remote)
get_public_feed ← all local public thoughts
get_user_feed ← one user's public thoughts
get_profile, get_top_friends
get_followers, get_following
list_api_keys
search
get_by_tag
get_notifications
```
---
## Federation & ActivityPub
`activitypub-base/` (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching.
`activitypub/` wires `activitypub-base` to the thoughts domain.
### Outbound (worker: domain event → AP activity → remote inboxes)
| Domain Event | AP Activity | Destination |
|------------------|---------------------|--------------------------|
| ThoughtCreated | Create(Note) | followers' inboxes |
| ThoughtDeleted | Delete(Note) | followers' inboxes |
| ThoughtUpdated | Update(Note) | followers' inboxes |
| LikeAdded | Like | thought author's inbox |
| LikeRemoved | Undo(Like) | thought author's inbox |
| BoostAdded | Announce | followers' inboxes |
| BoostRemoved | Undo(Announce) | followers' inboxes |
| FollowRequested | Follow | target's inbox |
| FollowAccepted | Accept(Follow) | requester's inbox |
| FollowRejected | Reject(Follow) | requester's inbox |
| Unfollowed | Undo(Follow) | target's inbox |
| UserBlocked | Block | blocked user's inbox |
### Inbound (`handle_inbox` use case)
| Incoming Activity | Use Case invoked |
|-------------------|----------------------------|
| Create(Note) | create_thought (remote) |
| Delete | delete_thought (remote) |
| Update(Note) | edit_thought (remote) |
| Like | like_thought (remote) |
| Undo(Like) | unlike_thought (remote) |
| Announce | boost_thought (remote) |
| Undo(Announce) | unboost_thought (remote) |
| Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) |
| Accept(Follow) | accept_follow |
| Reject(Follow) | reject_follow |
| Undo(Follow) | unfollow_user |
| Block | block_user (remote) |
### AP Endpoints (in presentation/)
```
GET /.well-known/webfinger
GET /.well-known/nodeinfo
GET /nodeinfo/2.0
GET /users/:username ← Actor object
GET /users/:username/inbox
POST /users/:username/inbox ← receives remote activities
GET /users/:username/outbox
GET /users/:username/followers
GET /users/:username/following
```
---
## Database Schema & Migration Strategy
### Remote thought caching
`likes` and `boosts` reference `thought_id UUID REFERENCES thoughts(id)`. When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in `thoughts` with `local = false`. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups.
### Migration approach
sqlx `migrations/` in `adapters/postgres/`. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes.
### Additive changes to existing tables
```sql
-- users: federation
ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE;
ALTER TABLE users ADD COLUMN inbox_url TEXT;
ALTER TABLE users ADD COLUMN public_key TEXT;
ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users
ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
-- thoughts: replies + AP + visibility
ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id);
ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent
ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE;
ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
ALTER TABLE thoughts ADD COLUMN content_warning TEXT;
ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ;
-- follows: pending state + AP id
ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted';
ALTER TABLE follows ADD COLUMN ap_id TEXT;
ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
```
### New tables
```sql
CREATE TABLE likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
thought_id UUID NOT NULL REFERENCES thoughts(id),
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE boosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
thought_id UUID NOT NULL REFERENCES thoughts(id),
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE blocks (
blocker_id UUID NOT NULL REFERENCES users(id),
blocked_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
display_name TEXT,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
public_key TEXT NOT NULL,
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
type TEXT NOT NULL, -- 'like','boost','follow','mention','reply'
from_user_id UUID REFERENCES users(id),
thought_id UUID REFERENCES thoughts(id),
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
---
## Event System & Worker
### event-payload/
Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). `serde::Serialize/Deserialize`. No domain dependency.
### event-publisher/
Receives `DomainEvent`, serializes to event-payload, routes to NATS subject (e.g. `thoughts.created`, `likes.added`). Implements domain's `EventPublisher` trait.
### nats/
Wraps `async-nats`. Implements `EventPublisher` (publish to subject) and `EventConsumer` (subscribe, yields `EventEnvelope` stream with ack/nack handles).
### worker/ (binary)
```
EventConsumer::consume()
→ deserialize EventEnvelope
→ match event type → dispatch to EventHandler impl
→ ack on success, nack on failure (NATS redelivers)
Handlers:
FederationHandler ← domain events → AP activities → remote inboxes
NotificationHandler ← writes notifications on like/boost/follow/mention/reply
SearchIndexHandler ← indexes/removes documents on create/delete
```
Handlers are plain structs taking `Arc<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.

View File

@@ -1,213 +0,0 @@
# Remote Actor Connections (Followers/Following) Design
Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution.
## Data Flow
1. User opens the Followers or Following tab on a remote actor profile
2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1`
3. Backend returns cached data immediately (may be empty on first visit)
4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget
5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
6. Next visit / tab re-open shows populated data
## Domain Changes
### New models (`domain/src/models/`)
**`connection_type.rs`**:
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionType {
Followers,
Following,
}
impl ConnectionType {
pub fn as_str(&self) -> &'static str {
match self { Self::Followers => "followers", Self::Following => "following" }
}
}
```
**`actor_connection_summary.rs`**:
```rust
#[derive(Debug, Clone)]
pub struct ActorConnectionSummary {
pub url: String, // AP URL of the connected actor
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
```
### New `DomainEvent` variant (`domain/src/events.rs`)
```rust
FetchActorConnections {
actor_ap_url: String,
collection_url: String,
connection_type: String, // "followers" | "following"
page: u32,
},
```
### New port (`domain/src/ports.rs`)
```rust
pub trait RemoteActorConnectionRepository: Send + Sync {
async fn upsert_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
actors: &[ActorConnectionSummary],
) -> Result<(), DomainError>;
async fn list_connections(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Vec<ActorConnectionSummary>, DomainError>;
async fn connection_page_age(
&self,
actor_url: &str,
connection_type: &str,
page: u32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
}
```
### New `FederationActionPort` method
```rust
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<ActorConnectionSummary>;
```
Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged).
## Storage
### Migration: `006_remote_actor_connections.sql`
```sql
CREATE TABLE remote_actor_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_url TEXT NOT NULL,
connection_type TEXT NOT NULL,
page INT NOT NULL,
connected_actor_url TEXT NOT NULL,
connected_handle TEXT NOT NULL,
connected_display_name TEXT,
connected_avatar_url TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(actor_url, connection_type, page, connected_actor_url)
);
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);
```
### `PgRemoteActorConnectionRepository`
- `upsert_connections`: `INSERT ... ON CONFLICT DO UPDATE SET connected_handle=EXCLUDED.connected_handle, connected_display_name=EXCLUDED.connected_display_name, connected_avatar_url=EXCLUDED.connected_avatar_url, fetched_at=NOW()`
- `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle`
- `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3`
## activitypub-base: `resolve_actor_profiles`
`ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`:
1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))`
2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id`
3. Collect `Ok` results → return as `Vec<ActorConnectionSummary>`
4. Failed/timed-out actors: `tracing::warn!` and skip
## event-payload
Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom<EventPayload>`, and uniqueness test.
## Worker
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`:
1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings
2. If empty: return Ok(()) — nothing to store
3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK
4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await`
5. Log: `tracing::info!(count = results.len(), "actor connections cached")`
Wire `remote_actor_connections` in `worker/src/factory.rs`.
## AppState + Bootstrap
Add `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`.
## REST Endpoints
**`GET /federation/actors/{handle}/followers-list?page=1`**
```
1. lookup_actor(handle) → get actor_ap_url + followers_url
2. list_connections(actor_ap_url, "followers", page) → cached items
3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget)
4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE }
```
`PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button.
**`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`.
Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`):
```json
{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." }
```
Define as a new `ActorConnectionResponse` in api-types.
Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it).
## Frontend
### `lib/api.ts`
```typescript
export const ActorConnectionSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
avatarUrl: z.string().nullable(),
url: z.string(),
});
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;
const ConnectionPageSchema = z.object({
items: z.array(ActorConnectionSchema),
page: z.number(),
hasMore: z.boolean(),
});
export const getActorFollowers = (handle, page, token) =>
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token);
export const getActorFollowing = (handle, page, token) =>
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token);
```
### `RemoteUserProfile` changes
Replace the plain "Followers / Following" link section with two client-side tabs. Each tab:
- Shows a list of `RemoteUserCard` components (reuse existing)
- "Load more" button if `hasMore`
- Empty state: "Loading — check back soon."
- Tab is lazy: only fetches when first opened (not on profile load)
Use the existing `RemoteUserCard` component — it already handles follow button and linking.
### `remote-user-profile.tsx` note
The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.