Mastic

About
Mastic is a federated social platform fully compatible with Mastodon and the Fediverse via ActivityPub, running entirely on the Internet Computer as Rust WASM canisters.
Documentation
Architecture
System architecture, canister design, and sequence diagrams for all major flows:
- Architecture Overview - Canister architecture, flows, and sequence diagrams
ActivityPub
ActivityPub protocol reference and Mastic-specific mapping:
- ActivityPub on Mastic - Protocol mapping, objects, actors, activities
Candid Interfaces
Canonical Candid interface definitions for each canister:
- Interface Definitions - Directory, Federation, and User canister interfaces
Project Specification
- Project Spec - User stories, milestones, and interface definitions
Architecture
- Architecture
This document describes the architecture of Mastic, detailing how each canister works internally, how they communicate with each other, and how data flows through the system.
Overview
Mastic is composed of three canister types and a frontend, all deployed on the Internet Computer:
block-beta
columns 2
Alice (("Alice")):2
block:mastic
columns 2
fe["Frontend"]:2
uc["User Canister"]
dir["Directory Canister"]
fed["Federation Canister"]:2
end
space
mastodon("Mastodon Web2"):2
Bob(("Bob")):2
Alice --> mastic
mastic --> mastodon
mastodon --> Bob
- Directory Canister – Global singleton. User registry, canister lifecycle management, and moderation.
- User Canister – One per user. Stores the user’s profile, statuses, inbox, and social graph. Acts as the ActivityPub actor.
- Federation Canister – Global singleton. HTTP boundary for all server-to-server ActivityPub traffic, WebFinger discovery, and activity routing.
- Frontend – React asset canister providing the web UI and Internet Identity authentication.
Directory Canister
The Directory Canister is the global entry point for Mastic. It maps Internet Identity principals to handles and User Canister IDs, manages the full canister lifecycle (creation and deletion), and enforces moderation policies.
Directory Responsibilities
- User registry – maintains the
userstable mapping each principal to a handle and an optional User Canister ID. - Sign-up – on
sign_up, validates the handle, inserts the user record, then spawns a worker that calls the IC management canister to create and install a new User Canister with the caller’s principal as owner. - Sign-in –
whoamianduser_canisterresolve the caller’s principal to their handle and User Canister ID. - Profile deletion – creates a tombstone for the user, notifies the
User Canister (which fans out a Delete activity), then destroys the
canister via
stop_canister+delete_canister. - Moderation – moderators (stored in the
moderatorstable) can suspend users and manage the moderator list. The initial moderator is set at install time. - Profile search –
search_profilesprovides paginated, case-insensitive substring lookup over registered handles. Only Active users with a canister are returned; suspended, pending, failed, and deletion-pending accounts are filtered out.
Directory Storage
Uses wasm-dbms for persistent
relational storage in stable memory. Tables are registered once during
init and survive upgrades without re-registration.
Three tables:
| Table | Purpose |
|---|---|
settings | Key-value configuration (federation canister) |
moderators | Moderator principals and creation timestamps |
users | Principal-to-handle-to-canister mapping |
See Database Schema for full column definitions.
Directory Install Arguments
Uses the Init / Upgrade enum variant pattern:
- Init – requires
initial_moderator(Principal),federation_canister(Principal), andpublic_url(String). Registers the database schema, seeds the first moderator, and stores the instance public URL. - Upgrade – empty variant. Validates that the caller did not
accidentally pass
Initargs on upgrade.
User Canister
Every Mastic user gets their own User Canister, created by the Directory Canister during sign-up. The User Canister is the ActivityPub actor: it owns the user’s profile, statuses, social graph, and cryptographic identity.
User Canister Responsibilities
- Profile management – single-row
profilestable stores display name, bio, avatar, and header image. Updated viaupdate_profile. - Status publishing –
publish_statusgenerates a Snowflake ID, persists the status in thestatusestable, then sends a Create activity to the Federation Canister for fan-out. - Feed aggregation –
read_feedmerges the user’s own statuses (outbox) with received activities (inbox) into a paginated, chronologically-sorted feed. - Social graph –
followersandfollowingtables track the user’s relationships. Follow requests go through Pending/Accepted states. Rejected follows are deleted so the user can re-follow. - Inbox –
receive_activity(called only by the Federation Canister) writes inbound ActivityPub activities into theinboxtable. - Outbound activities –
follow_user,like_status,boost_status,block_user, and their undo counterparts each send the corresponding ActivityPub activity to the Federation Canister viasend_activity. - HTTP Signatures – stores an RSA key pair (public + private PEM) in settings, used by the Federation Canister to sign outbound HTTP requests on behalf of this actor.
User Canister Storage
Uses wasm-dbms, same as the Directory Canister. Six tables:
| Table | Purpose |
|---|---|
settings | Owner principal, federation canister, RSA key pair |
profiles | Single-row profile (handle, display name, bio, etc.) |
statuses | User’s own statuses, keyed by Snowflake ID |
inbox | Inbound ActivityPub activities |
followers | Actor URIs of accounts following this user |
following | Actor URIs this user follows, with request status |
See Database Schema for full column definitions.
User Canister Install Arguments
- Init – requires
owner(Principal),federation_canister(Principal),handle(String), andpublic_url(String). Registers the database schema and stores all values in settings. - Upgrade – empty variant.
Custom Data Types
The User Canister defines three single-byte enum types for compact database storage:
- Visibility –
Public(0),Unlisted(1),FollowersOnly(2),Direct(3). Controls status distribution scope. - ActivityType – 14 variants (
Create,Update,Delete,Follow,Accept,Reject,Like,Announce,Undo,Block,Add,Remove,Flag,Move). Discriminates inbox activities. - FollowStatus –
Pending(0),Accepted(1). Tracks the lifecycle of follow requests. Rejected follows are deleted.
Federation Canister
The Federation Canister is the HTTP boundary of the Mastic node. It handles all server-to-server ActivityPub communication: receiving activities from remote Fediverse instances, sending activities out, and serving WebFinger discovery and actor profiles.
Federation Responsibilities
- Inbound HTTP –
http_request(query) andhttp_request_update(update) handle GET and POST requests from remote Fediverse instances. GET serves WebFinger, actor profiles, and collections. POST receives activities into user inboxes. - Outbound activities –
send_activity(called by User Canisters) routes activities to their destinations. For local recipients, it resolves the target handle via the Directory Canister and callsreceive_activityon the target User Canister. For remote recipients, it performs HTTPS outcalls with HTTP Signatures. - Activity buffering – during profile deletion, the Federation Canister buffers the Delete activity payload so it can still be served after the User Canister has been destroyed.
- WebFinger – responds to
/.well-known/webfinger?resource=acct:user@domainqueries, enabling remote instances to discover Mastic actors.
Federation Storage
Unlike the Directory and User canisters, the Federation Canister uses
ic-stable-structures directly (via IcMemoryManager with
DefaultMemoryImpl) rather than wasm-dbms. This is because the
Federation Canister primarily buffers transient data and does not need
a relational schema.
Federation Install Arguments
- Init – empty (no configuration fields required at install time).
Authorization Model
Mastic uses principal-based authorization. Each canister checks the caller’s principal against an expected set configured at install time.
graph LR
User([User]) -->|owner principal| UC[User Canister]
UC -->|registered principal| FED[Federation Canister]
FED -->|federation principal| UC
FED -->|directory principal| DIR[Directory Canister]
DIR -->|moderator principal| DIR
DIR -->|management canister| IC[IC Mgmt]
| Caller | Target | Trust Basis |
|---|---|---|
| User (browser) | User Canister | Caller matches owner principal set at install |
| User Canister | Federation Canister | User Canister registered by Directory at creation |
| Federation Canister | User Canister | Federation principal set in User Canister install args |
| Moderator | Directory Canister | Caller present in moderators table |
| Directory Canister | IC Management | Controller of dynamically-created User Canisters |
Anonymous principals are rejected by all authenticated endpoints.
Inter-Canister Communication
All inter-canister calls use standard Candid-encoded ic_cdk::call
invocations. The key communication patterns are:
- Activity fan-out – User Canister calls
send_activityon the Federation Canister. Federation resolves recipients (local via Directory, remote via HTTPS outcalls) and delivers to each inbox. - Activity delivery – Federation Canister calls
receive_activityon target User Canisters to deposit inbound activities. - Handle resolution – Federation Canister calls the Directory Canister to resolve actor handles to User Canister principals.
- Canister lifecycle – Directory Canister calls the IC management
canister (
create_canister,install_code,stop_canister,delete_canister) to manage User Canister instances.
Boost Flow
Boosting (Announce) requires denormalizing the original status content
into a wrapper row owned by the booster, so the feed can render the
boost without re-fetching on every read. The booster’s User Canister
never trusts content supplied by its caller; instead it fetches the
verified Status through the Federation Canister.
sequenceDiagram
actor A as Alice (booster)
participant UC as Booster User Canister
participant FED as Federation Canister
participant DIR as Directory Canister
participant TUC as Target User Canister (author)
participant Fol as Follower User Canisters
A->>UC: boost_status(status_url)
UC->>FED: fetch_status(uri, requester=alice_actor_uri)
FED->>DIR: lookup handle from URI
DIR-->>FED: target canister id
FED->>TUC: get_local_status(id, requester=alice_actor_uri)
TUC-->>FED: Status (visibility-filtered)
FED-->>UC: Status
UC->>UC: tx { wrapper Status, Boost row, FeedEntry } (shared snowflake)
UC->>FED: send_activity(Batch[Announce])
FED->>TUC: receive_activity(Announce) -- bumps boost_count
FED->>Fol: receive_activity(Announce) -- inbox row + feed entry
UC-->>A: Ok
A single Snowflake is reused as boosts.id, boosts.status_id, the
wrapper statuses.id, and the booster’s feed.id. The same Snowflake
also forms the canonical id of the emitted Announce activity:
<own_actor_uri>/statuses/<snowflake>.
Both boost_status and undo_boost are idempotent: a repeat call
returns Ok without inserting a duplicate row or re-dispatching the
activity. Remote URIs (host ≠ instance public_url) currently return
Unsupported from Federation.fetch_status; Milestone 3 will extend
the remote branch with HTTPS outcalls.
Shared Libraries
Four workspace crates provide shared functionality:
- did (
crates/libs/did) – Candid type definitions shared across all canisters. Defines request/response types,UserProfile,Status,Visibility, and install argument enums. - db-utils (
crates/libs/db-utils) – Database utilities includingHandleSanitizer,HandleValidator, and theSettingskey-value abstraction used by Directory and User canisters. - ic-utils (
crates/libs/ic-utils) – Test-friendly wrappers around IC APIs:caller(),now(),trap(). In unit tests these return dummy values instead of callingic_cdk. - activitypub (
crates/libs/activitypub) – W3C ActivityStreams 2.0 and ActivityPub protocol types:Activity,Actor,Object,PublicKey,WebFingerResponse, collection types, and Mastodon extensions.
Database Schema
- Database Schema
- Shared Settings Table
- Directory Canister
- User Canister
- User Canister Settings Keys
profilesTablestatusesTableinboxTablefollowersTablefollowingTablelikedTableblocksTablemutesTablebookmarksTableboostsTablemediaTableedit_historyTablehashtagsTablestatus_hashtagsTablefeatured_tagsTablepinned_statusesTableprofile_metadataTable
- Custom Data Types
- Persistence
Mastic uses wasm-dbms for persistent
storage inside the Directory and User canisters. Because wasm-dbms manages its
own stable memory, ic-stable-structures cannot be used alongside it in these
canisters. Configuration values that would normally live in stable cells are
stored in a shared settings key-value table instead.
The Federation Canister does not use wasm-dbms; it uses
ic-stable-structures directly.
Shared Settings Table
Both the Directory and User canisters include a settings table with the same
schema. Each row maps an integer key to a polymorphic value
(SettingValue, backed by the wasm-dbms Value type).
| Column | Type | Constraint | Description |
|---|---|---|---|
key | UINT16 | PRIMARY KEY | Setting identifier |
value | SettingValue | Typed value for the entry |
The Settings table and its helper methods (set_config_key,
get_required_settings_value, get_settings_value, get_as_principal) live in
the db-utils crate and are shared by both canisters.
Directory Canister
Directory Canister Settings Keys
| Key | Constant | Value Type | Description |
|---|---|---|---|
0 | SETTING_FEDERATION_CANISTER | BLOB | Principal of the Federation canister |
moderators Table
| Column | Type | Constraint | Description |
|---|---|---|---|
principal | Principal | PRIMARY KEY | The moderator’s principal |
created_at | UINT64 | Timestamp when added |
users Table
| Column | Type | Constraint | Description |
|---|---|---|---|
principal | Principal | PRIMARY KEY | The user’s principal |
handle | TEXT | UNIQUE, validated | User’s unique handle |
canister_id | Nullable<Principal> | UNIQUE | User Canister ID |
canister_status | CanisterStatus | Active, CreationPending, … | |
created_at | UINT64 | Timestamp when registered |
The handle column uses HandleSanitizer (trims whitespace, lowercases,
strips leading @) and HandleValidator (enforces the
handle rules). See the Handle Validation page
for the full specification.
tombstones Table
Retains deleted handles to block immediate re-registration and to keep
an audit trail. handle uses the same sanitizer/validator pair as the
users.handle column; see the Handle Validation spec.
| Column | Type | Constraint | Description |
|---|---|---|---|
handle | TEXT | PRIMARY KEY, sanitized, validated | Handle of the deleted user |
principal | Principal | Principal of the deleted user | |
deleted_at | UINT64 | Timestamp when the user was deleted |
reports Table
Stores user-submitted moderation reports. See the
Reports spec for validation rules on reason
and target_status_uri.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
reporter | Principal | Principal of the reporter | |
target_canister | Principal | Reported user’s canister principal | |
target_status_uri | Nullable<Text> | validated | URI of the reported status, if any |
reason | TEXT | sanitized, validated | Free-form reason |
state | ReportState | Open, Resolved, Dismissed | |
created_at | UINT64 | INDEX | Submission timestamp |
resolved_at | Nullable<Uint64> | Timestamp when the report was resolved | |
resolved_by | Nullable<Principal> | Moderator who resolved the report |
User Canister
User Canister Settings Keys
| Key | Constant | Value Type | Description |
|---|---|---|---|
0 | SETTING_FEDERATION_CANISTER | BLOB | Principal of the Federation canister |
1 | SETTING_OWNER_PRINCIPAL | BLOB | Principal of the canister owner |
2 | SETTING_PUBLIC_URL | TEXT | Public URL of the Mastic instance |
3 | SETTING_DIRECTORY_CANISTER | BLOB | Principal of the Directory canister |
profiles Table
Single-row table holding the owner’s profile.
| Column | Type | Constraint | Description |
|---|---|---|---|
principal | Principal | PRIMARY KEY | Owner’s principal |
handle | TEXT | UNIQUE, validated | User’s unique handle |
display_name | Nullable<Text> | Display name | |
bio | Nullable<Text> | Biography | |
avatar_data | Nullable<Blob> | Avatar image data | |
header_data | Nullable<Blob> | Header / banner data | |
created_at | UINT64 | Account creation time | |
updated_at | UINT64 | Last profile update |
statuses Table
See the Status Content spec for full validation
rules on content, spoiler_text, and in_reply_to_uri.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
content | TEXT | validated | Status body |
visibility | Visibility | Public, Unlisted, FollowersOnly, Direct | |
like_count | UINT64 | Cached Like count | |
boost_count | UINT64 | Cached Announce (boost) count | |
in_reply_to_uri | Nullable<Text> | INDEX, validated | URI of the replied-to status |
spoiler_text | Nullable<Text> | sanitized, validated | Optional content warning / spoiler |
sensitive | Boolean | Whether clients should hide behind a CW | |
edited_at | Nullable<Uint64> | Timestamp of the last edit | |
created_at | UINT64 | INDEX | Creation timestamp (indexed for feed ordering) |
inbox Table
Stores inbound ActivityPub activities.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
activity_type | ActivityType | Activity discriminator (Create, Follow, …) | |
actor_uri | TEXT | validated | Originating actor’s URI |
object_data | JSON | Activity object payload | |
is_boost | Boolean | true when entry is an Announce (boost) | |
original_status_uri | Nullable<Text> | validated | URI of the boosted status |
created_at | UINT64 | INDEX | Reception timestamp (indexed for feed ordering) |
follow_requests Table
| Column | Type | Constraint | Description |
|---|---|---|---|
actor_uri | TEXT | PRIMARY KEY | Requester’s actor URI |
created_at | UINT64 | Timestamp when follow request received |
followers Table
| Column | Type | Constraint | Description |
|---|---|---|---|
actor_uri | TEXT | PRIMARY KEY | Follower’s actor URI |
created_at | UINT64 | Timestamp when follow accepted |
following Table
| Column | Type | Constraint | Description |
|---|---|---|---|
actor_uri | TEXT | PRIMARY KEY | Followed actor’s URI |
status | FollowStatus | Pending or Accepted (rejected entries are deleted) | |
created_at | UINT64 | Timestamp when follow was requested |
liked Table
| Column | Type | Constraint | Description |
|---|---|---|---|
status_uri | TEXT | PRIMARY KEY, validated | URI of the liked status |
created_at | UINT64 | Timestamp when the status was liked |
blocks Table
| Column | Type | Constraint | Description |
|---|---|---|---|
actor_uri | TEXT | PRIMARY KEY, validated | URI of the blocked actor |
created_at | UINT64 | Timestamp when block was added |
mutes Table
| Column | Type | Constraint | Description |
|---|---|---|---|
actor_uri | TEXT | PRIMARY KEY, validated | URI of the muted actor |
created_at | UINT64 | Timestamp when mute was added |
bookmarks Table
| Column | Type | Constraint | Description |
|---|---|---|---|
status_uri | TEXT | PRIMARY KEY, validated | URI of the bookmarked status |
created_at | UINT64 | Timestamp when bookmark was set |
boosts Table
Tracks Announce activities emitted by the user. Each boost is
paired with a wrapper row in the statuses table.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
status_id | UINT64 | FK → statuses.id | Wrapper status row |
original_status_uri | TEXT | validated | URI of the boosted status |
created_at | UINT64 | INDEX | Timestamp when the boost was emitted |
The same Snowflake is reused as boosts.id, boosts.status_id, the
wrapper statuses.id, and the wrapper’s feed.id — making the
wrapper status URL <actor>/statuses/<snowflake> also the canonical
id of the emitted Announce activity. One sequence increment per
boost; one URL that dereferences both the wrapper status and the boost
activity.
media Table
See the Media Attachments spec for full validation
rules on media_type, description, and blurhash.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
status_id | UINT64 | FK → statuses.id, INDEX | Parent status |
media_type | TEXT | validated | MIME-like media type |
description | Nullable<Text> | sanitized, validated | Alt-text description |
blurhash | Nullable<Text> | validated | Blurhash preview string |
bytes | BLOB | Raw media bytes | |
created_at | UINT64 | Creation timestamp |
edit_history Table
previous_spoiler_text uses the same sanitizer/validator pair as
statuses.spoiler_text; see the Status Content
spec.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
status_id | UINT64 | FK → statuses.id, INDEX | Status this entry belongs to |
previous_content | TEXT | Content before the edit | |
previous_spoiler_text | Nullable<Text> | sanitized, validated | Spoiler text before the edit |
edited_at | UINT64 | INDEX | Timestamp of the edit |
hashtags Table
Local per-user index of hashtags referenced by the user’s statuses.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
tag | TEXT | UNIQUE, validated | Sanitized, lowercase tag (without #) |
created_at | UINT64 | Timestamp when the hashtag was first seen |
The tag column uses HashtagSanitizer (trims whitespace, lowercases,
strips leading #) and HashtagValidator. See the
Hashtag Validation page for the full
specification.
status_hashtags Table
Join table between statuses and hashtags. Uses a surrogate id
primary key because the underlying storage layer does not support
composite primary keys; uniqueness of (status_id, hashtag_id) is
enforced by the application layer.
| Column | Type | Constraint | Description |
|---|---|---|---|
id | UINT64 | PRIMARY KEY | Snowflake ID |
status_id | UINT64 | FK → statuses.id, INDEX | Status |
hashtag_id | UINT64 | FK → hashtags.id, INDEX | Hashtag |
featured_tags Table
Up to four hashtags featured on the user’s profile. The tag column
uses the same HashtagSanitizer / HashtagValidator
pair as the hashtags table.
| Column | Type | Constraint | Description |
|---|---|---|---|
tag | TEXT | PRIMARY KEY, validated | Sanitized, lowercase tag |
position | UINT8 | UNIQUE (0..=3) | Display position |
created_at | UINT64 | Timestamp when tag was featured |
pinned_statuses Table
Up to five statuses pinned on the user’s profile.
| Column | Type | Constraint | Description |
|---|---|---|---|
status_id | UINT64 | PRIMARY KEY, FK → statuses.id | Pinned status |
position | UINT8 | UNIQUE (0..=4) | Display position |
pinned_at | UINT64 | Timestamp when status was pinned |
profile_metadata Table
Up to four custom fields shown on the user’s profile. See the Profile Metadata spec for full validation rules.
| Column | Type | Constraint | Description |
|---|---|---|---|
position | UINT8 | PRIMARY KEY (0..=3) | Position in the list |
name | TEXT | sanitized, validated | Field name |
value | TEXT | sanitized, validated | Field value |
Custom Data Types
The following custom types are used in the schema and stored as compact single-byte discriminants:
Visibility
Maps to did::common::Visibility.
| Value | Variant |
|---|---|
0 | Public |
1 | Unlisted |
2 | FollowersOnly |
3 | Direct |
ActivityType
Maps to activitypub::ActivityType.
| Value | Variant |
|---|---|
0 | Create |
1 | Update |
2 | Delete |
3 | Follow |
4 | Accept |
5 | Reject |
6 | Like |
7 | Announce |
8 | Undo |
9 | Block |
10 | Add |
11 | Remove |
12 | Flag |
13 | Move |
FollowStatus
| Value | Variant |
|---|---|
0 | Pending |
1 | Accepted |
ReportState
| Value | Variant |
|---|---|
0 | Open |
1 | Resolved |
2 | Dismissed |
Persistence
All tables are created during canister init. Data survives canister upgrades
because wasm-dbms stores everything in stable memory. The post_upgrade
function does not need to re-register the schema.
ActivityPub on Mastic
- ActivityPub on Mastic
- Mapping to Mastic Architecture
- Objects
- Actors
- Activity Streams
- Protocol
- Social API
- Federation Protocol
- Mastodon
- Statuses Federation
- Profiles Federation
- Reports Extension
- Sensitive Extension
- Hashtag
- Custom Emoji
- Focal Points
- Quote Posts
- Discoverability Flag
- Indexable Flag
- Suspended Flag
- Memorial Flag
- Polls
- Mentions
- Public Key
- Blurhash
- Featured Collection
- Featured Tags
- Profile Metadata
- Account Migration
- Remote Blocking
- Secure Mode
- Follower Synchronization
- HTTP Signatures
- WebFinger
This module provides a technical overview with simple diagrams of the ActivityPub protocol, which is used for federated social networking.
The diagrams illustrate the flow of activities between actors in a federated network, showing how they interact with each other through various endpoints, and so what it has to be implemented in order to support ActivityPub on Mastic.
Mapping to Mastic Architecture
The following table shows how core ActivityPub concepts map to Mastic’s canister-based architecture:
| ActivityPub Concept | Mastic Component | Notes |
|---|---|---|
| Actor | User Canister | Each Mastic user is represented by a dedicated User Canister that acts as their ActivityPub Actor. |
| Inbox / Outbox | User Canister | The actor’s inbox and outbox collections are stored in the User Canister. They are exposed to the Fediverse via HTTP endpoints served by the Federation Canister. |
| Social API (C2S) | Candid calls to User Canister | Instead of HTTP-based Client-to-Server interactions, Mastic users interact with their User Canister through authenticated Candid calls, using Internet Identity for authentication. |
| Federation Protocol (S2S) | Federation Canister | All Server-to-Server HTTP traffic is handled by the Federation Canister, which receives incoming activities and forwards outgoing activities to remote instances. |
| HTTP Signatures | User Canister (key storage) + Federation Canister (signing/verification) | Each User Canister generates and stores an RSA key pair at creation time. The Federation Canister uses the private key to sign outgoing requests and serves the public key when the actor profile is requested. |
| WebFinger | Federation Canister | WebFinger lookups (/.well-known/webfinger) are handled by the Federation Canister’s http_request query method, which resolves account handles to actor URIs via the Directory Canister. |
Federation Canister HTTP Endpoints
The Federation Canister serves the following HTTP routes to enable ActivityPub federation and discovery:
| Method | Route | Description |
|---|---|---|
GET | /.well-known/webfinger | WebFinger lookup — resolves acct: URIs to actor profiles via the Directory Canister |
GET | /users/{handle} | Actor profile — returns the JSON-LD representation of the actor |
GET | /users/{handle}/inbox | Actor inbox — returns the inbox as an OrderedCollection |
POST | /users/{handle}/inbox | Receive activities from remote instances (S2S) — validates HTTP Signatures and delivers to the User Canister |
GET | /users/{handle}/outbox | Actor outbox — returns the outbox as an OrderedCollection |
GET | /users/{handle}/followers | Followers collection — returns the actor’s followers as an OrderedCollection |
GET | /users/{handle}/following | Following collection — returns the actors followed by this actor as an OrderedCollection |
GET | /users/{handle}/liked | Liked collection — returns the activities liked by this actor as an OrderedCollection |
All GET endpoints are served by the Federation Canister’s http_request query method. The POST /users/{handle}/inbox endpoint is handled by http_request_update since it requires state changes (delivering activities to User Canisters).
Objects
All objects in ActivityPub are represented as JSON-LD documents. The objects can be of various types, such as Person, Note, Create, Like, etc.
Each object MUST have:
id: The object’s unique global identifier (unless the object is transient, in which case the id MAY be omitted).type: The type of the object.
and can have various properties such as to, actor, and content.
Retrieving objects
Servers MUST present the ActivityStreams object representation in response to application/ld+json; profile="https://www.w3.org/ns/activitystreams", and SHOULD also present the ActivityStreams representation in response to application/activity+json as well.
The client MUST specify an Accept header with the application/ld+json; profile="https://www.w3.org/ns/activitystreams" media type in order to retrieve the activity.
Source
The Object also contains the source attribute, which has been originally used to derive the content:
{
"content": "<p>I <em>really</em> like strawberries!</p>",
"source": {
"content": "I *really* like strawberries!",
"mediaType": "text/markdown"
}
}
Actors
Actors are the entities that perform actions in the ActivityPub protocol. They can be users, applications, or services. Each actor has a unique identifier and can have various properties such as name, icon, and preferred language.
Actors are represented as JSON-LD documents with the type set to Person, Application, or other types defined in the ActivityStreams vocabulary.
Each actor MUST, in addition to the properties for the Objects, have the following properties:
inbox: (OrderedCollection) The URL of the actor’s inbox, where it receives activities.outbox: (OrderedCollection) The URL of the actor’s outbox, where it sends activities.following: (OrderedCollection) An Url to an ActivityStreams collection that contains the actors that this actor is following.followers: (OrderedCollection) An Url to an ActivityStreams collection that contains the actors that are following this actor.liked: (OrderedCollection) An Url to an ActivityStreams collection that contains the activities that this actor has liked.
{
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "ja" }],
"type": "Person",
"id": "https://kenzoishii.example.com/",
"following": "https://kenzoishii.example.com/following.json",
"followers": "https://kenzoishii.example.com/followers.json",
"liked": "https://kenzoishii.example.com/liked.json",
"inbox": "https://kenzoishii.example.com/inbox.json",
"outbox": "https://kenzoishii.example.com/feed.json",
"preferredUsername": "kenzoishii",
"name": "石井健蔵",
"summary": "この方はただの例です",
"icon": ["https://kenzoishii.example.com/image/165987aklre4"]
}
Inbox and Outbox
Every actor has both an inbox and an outbox. The inbox is where the actor receives activities from other actors, while the outbox is where the actor sends activities to other actors.
From an implementation perspective, both the Inbox and the Outbox, are OrderedCollection objects.
block-beta
columns 7
U (["User"])
space:2
block:queues
columns 1
IN ("Inbox")
OUT ("Outbox")
end
space:2
RO ("Rest of the world")
U -- "POST messages to" --> OUT
IN -- "GET messages from" --> U
RO -- "POST messages to" --> IN
OUT -- "GET messages from" --> RO
Actor data:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Person",
"id": "https://social.example/alice/",
"name": "alice P. Hacker",
"preferredUsername": "alice",
"summary": "Lisp enthusiast hailing from MIT",
"inbox": "https://social.example/alice/inbox/",
"outbox": "https://social.example/alice/outbox/",
"followers": "https://social.example/alice/followers/",
"following": "https://social.example/alice/following/",
"liked": "https://social.example/alice/liked/"
}
Now let’s say Alice wants to send a message to Bob. The following diagram illustrates the flow of this activity:
block-beta
columns 13
A (["Alice"])
space
block:aliq
columns 1
AIN ("Inbox")
AOUT ("Outbox")
end
space
AS ("Alice's Server")
space:2
BS ("Bob's Server")
space
block:bobq
columns 1
BIN ("Inbox")
BOUT ("Outbox")
end
space:2
B ("Bob")
A -- "POST message" --> AOUT
AOUT --> AS
AS -- "POST message to" --> BS
BS --> BIN
BIN -- "GET message" --> B
First Alice sends a message to her outbox:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"to": ["https://chatty.example/ben/"],
"attributedTo": "https://social.example/alice/",
"content": "Say, did you finish reading that book I lent you?"
}
Then Alice’s server creates the post and forwards the message to Bob’s server:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://social.example/alice/posts/a29a6843-9feb-4c74-a7f7-081b9c9201d3",
"to": ["https://chatty.example/ben/"],
"actor": "https://social.example/alice/",
"object": {
"type": "Note",
"id": "https://social.example/alice/posts/49e2d03d-b53a-4c4c-a95c-94a6abf45a19",
"attributedTo": "https://social.example/alice/",
"to": ["https://chatty.example/ben/"],
"content": "Say, did you finish reading that book I lent you?"
}
}
Later after Bob has answered, Alice can fetch her inbox with a GET and see the answer to that message:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://chatty.example/ben/p/51086",
"to": ["https://social.example/alice/"],
"actor": "https://chatty.example/ben/",
"object": {
"type": "Note",
"id": "https://chatty.example/ben/p/51085",
"attributedTo": "https://chatty.example/ben/",
"to": ["https://social.example/alice/"],
"inReplyTo": "https://social.example/alice/posts/49e2d03d-b53a-4c4c-a95c-94a6abf45a19",
"content": "<p>Argh, yeah, sorry, I'll get it back to you tomorrow.</p><p>I was reviewing the section on register machines,since it's been a while since I wrote one.</p>"
}
}
Further interactions can be made, such as liking the reply:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Like",
"id": "https://social.example/alice/posts/5312e10e-5110-42e5-a09b-934882b3ecec",
"to": ["https://chatty.example/ben/"],
"actor": "https://social.example/alice/",
"object": "https://chatty.example/ben/p/51086"
}
And this will follow the same flow as before.
Activity Streams
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://www.w3.org/ns/activitystreams",
"type": "Collection"
}
ActivityStreams defines the collection concept; ActivityPub defines several collections with special behavior. Note that ActivityPub makes use of ActivityStreams paging to traverse large sets of objects.
Note that some of these collections are specified to be of type
OrderedCollectionspecifically, while others are permitted to be either aCollectionor anOrderedCollection. AnOrderedCollectionMUST be presented consistently in reverse chronological order.
Public collections
Some collections are marked as Public
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://www.w3.org/ns/activitystreams#Public",
"type": "Collection"
}
And MUST be accessible to anyone, regardless of whether they are authenticated or not.
Likes Collection
Objects MAY have a likes collection. This is an OrderedCollection containing all Like activities referencing that object. Servers SHOULD update this collection when a Like or Undo Like activity is received. The collection is typically exposed with at least a totalItems count.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/alice/posts/1/likes",
"type": "OrderedCollection",
"totalItems": 5,
"orderedItems": [
"https://social.example/bob/likes/1",
"https://social.example/carol/likes/3"
]
}
Shares Collection
Objects MAY have a shares collection. This is an OrderedCollection containing all Announce activities referencing that object. Servers SHOULD update this collection when an Announce or Undo Announce activity is received. Like the likes collection, it is typically exposed with at least a totalItems count.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/alice/posts/1/shares",
"type": "OrderedCollection",
"totalItems": 3,
"orderedItems": [
"https://chatty.example/ben/announces/1"
]
}
Protocol
The protocol is based on HTTP and uses JSON-LD for data representation.
Two APIs are defined:
- Social API: It’s a client-to-server API that allows clients to interact with the server, such as creating posts, following users, and liking content.
- Federation Protocol: It’s a server-to-server API that allows servers to exchange activities with each other, such as sending posts, following users, and liking content.
Mastic is implemented as a ActivityPub conformant Federated Server, with a significant variation.
While the Federation Protocol is implemented with HTTP, the Social API is implemented using the Internet Computer’s native capabilities, such as update calls and query calls, and calls are so authenticated using the Internet Computer’s Internet Identity.
Social API
In the standard ActivityPub specification, client-to-server (C2S) interaction takes place through clients posting Activities to an actor’s outbox via HTTP POST requests. Mastic replaces this HTTP-based C2S layer with typed Candid methods on the User Canister.
Instead of discovering an outbox URL and POSTing JSON-LD payloads to it, Mastic users call specific Candid methods on their User Canister, such as publish_status, like_status, follow_user, boost_status, block_user, etc. Each method accepts a typed Candid argument struct and returns a typed response. The User Canister internally manages the actor’s outbox, appending the corresponding ActivityPub activity for each operation.
Requests MUST be authenticated using the Internet Computer’s Internet Identity — the caller’s principal must match the owner principal configured at canister install time.
The User Canister handles the same side effects that the ActivityPub spec requires for outbox operations:
- The server (User Canister) MUST generate a new
idfor each Activity. - The server MUST remove
btoand/orbccproperties before delivery, but MUST utilize the addressing originally stored on these properties for determining recipients. - The server MUST add the new Activity to the outbox collection.
- Depending on the type of Activity, the server may carry out further side effects as described per individual Activity below.
Client Addressing
Clients are responsible for addressing new Activities appropriately. To some extent, this is dependent upon the particular client implementation, but clients must be aware that the server will only forward new Activities to addressees in the to, bto, cc, bcc, and audience fields.
Create Activity
The Create activity is used when posting a new object. This has the side effect that the object embedded within the Activity (in the object property) is created.
When a Create activity is posted, the actor of the activity SHOULD be copied onto the object’s attributedTo field.
For client to server posting, it is possible to submit an object for creation without a surrounding activity. The server MUST accept a valid ActivityStreams object that isn’t a subtype of Activity in the POST request to the outbox. The server then MUST attach this object as the object of a Create Activity. For non-transient objects, the server MUST attach an id to both the wrapping Create and its wrapped Object.
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"content": "This is a note",
"published": "2015-02-10T15:04:55Z",
"to": ["https://example.org/~john/"],
"cc": ["https://example.com/~erik/followers",
"https://www.w3.org/ns/activitystreams#Public"]
}
Is equivalent to this and both MUST be accepted by the server:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.net/~mallory/87374",
"actor": "https://example.net/~mallory",
"object": {
"id": "https://example.com/~mallory/note/72",
"type": "Note",
"attributedTo": "https://example.net/~mallory",
"content": "This is a note",
"published": "2015-02-10T15:04:55Z",
"to": ["https://example.org/~john/"],
"cc": ["https://example.com/~erik/followers",
"https://www.w3.org/ns/activitystreams#Public"]
},
"published": "2015-02-10T15:04:55Z",
"to": ["https://example.org/~john/"],
"cc": ["https://example.com/~erik/followers",
"https://www.w3.org/ns/activitystreams#Public"]
}
Update Activity
The Update activity is used when updating an already existing object. The side effect of this is that the object MUST be modified to reflect the new structure as defined in the update activity, assuming the actor has permission to update this object.
Usually updates are partial.
Delete Activity
The Delete activity is used to delete an already existing object
The side effect of this is that the server MAY replace the object with a Tombstone of the object that will be displayed in activities which reference the deleted object. If the deleted object is requested the server SHOULD respond with either the Gone status code if a Tombstone object is presented as the response body, otherwise respond with a NotFound.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/~alice/note/72",
"type": "Tombstone",
"published": "2015-02-10T15:04:55Z",
"updated": "2015-02-10T15:04:55Z",
"deleted": "2015-02-10T15:04:55Z"
}
Follow Activity
The Follow activity is used to subscribe to the activities of another actor.
Add Activity
Upon receipt of an Add activity into the outbox, the server SHOULD add the object to the collection specified in the target property, unless:
- the
targetis not owned by the receiving server, and thus they are not authorized to update it. - the
objectis not allowed to be added to thetargetcollection for some other reason, at the receiving server’s discretion.
Remove Activity
Upon receipt of a Remove activity into the outbox, the server SHOULD remove the object from the collection specified in the target property, unless:
- the
targetis not owned by the receiving server, and thus they are not authorized to update it. - the
objectis not allowed to be removed from thetargetcollection for some other reason, at the receiving server’s discretion.
Like Activity
The Like activity indicates the actor likes the object.
The side effect of receiving this in an outbox is that the server SHOULD add the object to the actor’s liked Collection.
Block Activity
The Block activity is used to indicate that the posting actor does not want another actor (defined in the object property) to be able to interact with objects posted by the actor posting the Block activity. The server SHOULD prevent the blocked user from interacting with any object posted by the actor.
Servers SHOULD NOT deliver Block Activities to their object.
Announce Activity
The Announce activity is used to share an existing object with the actor’s followers — this is the ActivityPub equivalent of a “boost” or “reblog”. When an Announce is posted to an actor’s outbox, the server SHOULD add the object of the Announce to the actor’s outbox as a reference and deliver it to the actor’s followers.
The side effect of receiving an Announce on the inbox (S2S) is that the server SHOULD increment the shares count of the announced object and MAY display the object as having been shared by the announcing actor.
Mastic implementation (M1): Outbound
Announceis emitted by the User Canister’sboost_statusflow (withUndo(Announce)fromundo_boost). InboundAnnounceis processed byhandle_announceincrates/canisters/user/src/domain/activity/handle_incoming.rs, which inserts an inbox row, a feed entry, and increments the local target’sstatuses.boost_count(saturating). InboundUndo(Announce)is dispatched tohandle_undo_announce, which deletes the inbox / feed rows and saturating-decrements the boost count.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/alice/posts/announce-1",
"type": "Announce",
"actor": "https://social.example/alice",
"published": "2024-06-01T12:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://social.example/alice/followers"],
"object": "https://chatty.example/ben/p/51086"
}
Undo Activity
The Undo activity is used to undo a previous activity. See the Activity Vocabulary documentation on Inverse Activities and “Undo”. For example, Undo may be used to undo a previous Like, Follow, or Block. The undo activity and the activity being undone MUST both have the same actor. Side effects should be undone, to the extent possible. For example, if undoing a Like, any counter that had been incremented previously should be decremented appropriately.
There are some exceptions where there is an existing and explicit “inverse activity” which should be used instead. Create based activities should instead use Delete, and Add activities should use Remove.
Delivery
Federated servers MUST perform delivery on all Activities posted to the outbox according to outbox delivery.
Federation Protocol
Servers communicate with other servers and propagate information across the social graph by posting activities to actors’ inbox endpoints. An Activity sent over the network SHOULD have an id, unless it is intended to be transient (in which case it MAY omit the id).
POST requests (eg. to the inbox) MUST be made with a Content-Type of application/ld+json; profile="https://www.w3.org/ns/activitystreams" and GET requests (see also 3.2 Retrieving objects) with an Accept header of application/ld+json; profile="https://www.w3.org/ns/activitystreams".
Servers SHOULD interpret a Content-Type or Accept header of application/activity+json as equivalent to application/ld+json; profile="https://www.w3.org/ns/activitystreams“ for server-to-server interactions.
In order to propagate updates throughout the social graph, Activities are sent to the appropriate recipients. First, these recipients are determined through following the appropriate links between objects until you reach an actor, and then the Activity is inserted into the actor’s inbox (delivery). This allows recipient servers to:
- conduct any side effects related to the Activity (for example, notification that an actor has liked an object is used to update the object’s like count);
- deliver the Activity to recipients of the original object, to ensure updates are propagated to the whole social graph (see inbox delivery).
Delivery is usually triggered by, for example:
- an Activity being created in an actor’s outbox with their Followers Collection as the recipient.
- an Activity being created in an actor’s outbox with directly addressed recipients.
- an Activity being created in an actors’s outbox with user-curated collections as recipients.
- an Activity being created in an actor’s outbox or inbox which references another object.
Servers performing delivery to the inbox or sharedInbox properties of actors on other servers MUST provide the object property in the activity: Create, Update, Delete, Follow, Add, Remove, Like, Block, Undo. Additionally, servers performing server to server delivery of the following activities MUST also provide the target property: Add, Remove.
An activity is delivered to its targets (which are actors) by first looking up the targets’ inboxes and then posting the activity to those inboxes. Targets for delivery are determined by checking the ActivityStreams audience targeting; namely, the to, bto, cc, bcc, and audience fields of the activity.
Accept Activity
The Accept activity is used on the server side (S2S) to approve a previously received Follow request. When a server receives a Follow activity in an actor’s inbox, the server SHOULD either automatically respond with an Accept (for unlocked accounts) or queue the follow request for manual approval. Once approved, the server sends an Accept wrapping the original Follow back to the requesting actor’s inbox.
The side effect of receiving an Accept of a Follow is that the requesting actor is added to the target actor’s followers collection, and the target actor is added to the requesting actor’s following collection.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/alice/accepts/1",
"type": "Accept",
"actor": "https://social.example/alice",
"object": {
"id": "https://chatty.example/ben/follows/1",
"type": "Follow",
"actor": "https://chatty.example/ben",
"object": "https://social.example/alice"
}
}
Reject Activity
The Reject activity is used on the server side (S2S) to deny a previously received Follow request. When a locked account’s owner denies a follow request, the server sends a Reject wrapping the original Follow back to the requesting actor’s inbox.
The side effect of receiving a Reject of a Follow is that the follow relationship is not established and the requesting server SHOULD remove any pending follow state.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/alice/rejects/1",
"type": "Reject",
"actor": "https://social.example/alice",
"object": {
"id": "https://chatty.example/ben/follows/1",
"type": "Follow",
"actor": "https://chatty.example/ben",
"object": "https://social.example/alice"
}
}
Shared Inbox Delivery
To efficiently deliver activities to multiple recipients on the same remote server, ActivityPub defines the shared inbox mechanism. Instead of sending a separate copy of an activity to each recipient’s individual inbox, the sending server MAY deliver a single copy to the remote server’s shared inbox endpoint.
An actor MAY advertise a shared inbox via the endpoints.sharedInbox property:
{
"id": "https://social.example/alice",
"type": "Person",
"inbox": "https://social.example/alice/inbox",
"endpoints": {
"sharedInbox": "https://social.example/inbox"
}
}
When delivering to a shared inbox, the activity MUST still contain its full addressing (to, cc, etc.) so the receiving server can determine which local actors should see it. The sending server SHOULD use the shared inbox when an activity is addressed to multiple recipients on the same domain.
Mastic implementation: In Mastic, the Federation Canister acts as the shared inbox endpoint for all local users. The
POST /inboxendpoint (without a handle) can serve as the shared inbox, routing activities to the appropriate User Canisters based on the addressing fields.
Inbox Forwarding
When an activity is received in an actor’s inbox and the activity’s object is addressed to recipients that the receiving server is aware of but which are not explicitly addressed in the activity itself, the server SHOULD forward the activity to those recipients. This mechanism ensures that replies in a conversation thread are propagated to all participants.
A server MUST only forward an activity if:
- The values of
to,cc, oraudienceare collections owned by the server. - The values of
inReplyTo,object,target, ortagare objects owned by the server. The server SHOULD recurse through these values to look for linked objects owned by the server. - The values of the
objectortargetmatch a collection owned by the server (e.g., the followers collection).
When forwarding, the server MUST sign the forwarded request with an HTTP Signature from the forwarding actor (not the original actor).
Server Side Activities
Just follow 1:1 the document described here:
https://www.w3.org/TR/activitypub/#create-activity-inbox
Mastodon
Note: Not all Mastodon extensions described in this section will be implemented in the initial milestones. Features such as pinned posts (Featured Collection), Flag/reporting, and Move/account migration are documented here for completeness and future reference. See the milestone plan in
docs/project.mdfor the implementation timeline.
Statuses Federation
In Mastodon statuses are posts, aka toots, of the type of Notes of the ActivityPub protocol.
Mastodon supports the following activities for Statuses:
Create: Transformed into a status and saved into databaseUpdate: Update an existing status in the databaseDelete: Delete a Status from the databaseLike: Favourited a StatusAnnounce: Boost a status (like rt on Twitter). Mastic M1: outbound viaboost_status, inbound viahandle_announce.Undo: Undo a Like or a Boost. Mastic M1: outbound viaundo_like/undo_boost, inbound viahandle_undo_like/handle_undo_announce.Flag: Transformed into a report to the moderation team. See the Reports extension for more informationQuoteRequest: Request approval for a quote post. See the Quote Posts extension
Payloads
The first-class Object types supported by Mastodon are Note and Question. Additionally, the following types are accepted and transformed into statuses:
Note— transformed into regular statuses.Question— transformed into a poll status. See the Polls extension for more information.Article— long-form content, transformed into a status.Page— web page representation, transformed into a status.Image— image post, transformed into a status with media attachment.Audio— audio post, transformed into a status with audio attachment.Video— video post, transformed into a status with video attachment.Event— event representation, transformed into a status.
HTML Sanitization
https://docs.joinmastodon.org/spec/activitypub/#sanitization.
Status Properties
These are the properties used:
content: status text contentname: Used as status text, if content is not provided on a transformed Object typesummary: Used as CW (Content warning) textsensitive: Used to determine whether status media or text should be hidden by default. See the Sensitive content extension section for more information about as:sensitiveinReplyTo: Used for threading a status as a reply to another statuspublished: status published dateurl: status permalinkattributedTo: Used to determine the profile which authored the statusto/cc: Used to determine audience and visibility of a status, in combination with mentions. See Mentions for adddressing and notifications.tag: Used to mark up mentions and hashtags.type: Either Mention, Hashtag, or Emoji is currently supported. See the Hashtag and Custom emoji extension sections for more information.name: The plain-text Webfinger address of a profile Mention (@useror@user@domain), or the plain-text Hashtag (#tag), or the custom Emoji shortcode (:thounking:)href: The URL of the actor or tag
attachment: Used to include attached images, videos, or audiourl: Used to fetch the media attachmentsummary: Used as media descriptionaltblurhash: Used to generate a blurred preview image corresponding to the colors used within the image. See Blurhash for more details
replies: A Collection ofstatusesthat are in reply to the current status. Up to 5 replies from the same server will be fetched upon discovery of a remote status, in order to resolve threads more fully. On Mastodon’s side, the first page contains self-replies, and additional pages contain replies from other people.likes: A Collection used to represent Like activities received for this status. The actual activities are not exposed by Mastodon at this time.totalItems: The number of likes this status has received
shares: A Collection used to represent Announce activities received for this status. The actual activities are not exposed by Mastodon at this time.totalItems: The number of Announce activities received for this status.
Poll specific properties
endTime: The timestamp for when voting will close on the pollclosed: The timestamp for when voting closed on the poll. The timestamp will likely match the endTime timestamp. If this property is present, the poll is assumed to be closed.votersCount: How many people have voted in the poll. Distinct from how many votes have been cast (in the case of multiple-choice polls)oneOf: Single-choice poll optionsname: The poll option’s textreplies:totalItems: The poll option’s vote count
anyOf: Multiple-choice poll optionsname: The poll option’s textreplies:totalItems: The poll option’s vote count
Profiles Federation
Profiles are represented as Person objects in ActivityPub, and they are used to represent users on the platform. Mastodon supports the following activities for profiles:
Follow: Indicate interest in receiving status updates from a profile.Accept/Reject: Used to approve or deny Follow activities. Unlocked accounts will automatically reply with an Accept, while locked accounts can manually choose whether to approve or deny a follow request.Add/Remove: Manage pinned posts and featured collections.Update: Refresh account detailsDelete: Remove an account from the database, as well as all of their statuses.Undo: Undo a previous Follow, Accept Follow, or Block.Block: Signal to a remote server that they should hide your profile from that user. Not guaranteed.Flag: Report a user to their moderation team. See the Reports extension for more informationMove: Migrate followers from one account to another. RequiresalsoKnownAsto be set on the new account pointing to the old account
Profile Properties
preferredUsername: Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfingeracct:URI.name: Used as profile display name.summary: Used as profile bio.type: Assumed to bePerson. If type isApplicationorService, it will be interpreted as a bot flag.url: Used as profile link.icon: Used as profile avatar.image: Used as profile header.manuallyApprovesFollowers: Will be shown as a locked account.discoverable: Will be shown in the profile directory.indexable: Posts by this account can be indexed for full-text searchpublicKey: Required for signatures. See Public Key for more information.featured: Pinned posts. See Featured collectionattachment: Used for profile fields. See Profile metadata.alsoKnownAs: Required forMoveactivitypublished: When the profile was created.memorial: Whether the account is a memorial account. See Memorial Flag for more information.suspended: Whether the account is currently suspended. See Suspended Flag for more information.attributionDomains: Domains allowed to usefediverse:creatorfor this actor in published articles.
Reports Extension
To report profiles and/or posts on remote servers, Mastodon will send a Flag activity from the instance actor. The object of this activity contains the user being reported, as well as any posts attached to the report. If a comment is attached to the report, it will be used as the content of the activity.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.example/ccb4f39a-506a-490e-9a8c-71831c7713a4",
"type": "Flag",
"actor": "https://mastodon.example/actor",
"content": "Please take a look at this user and their posts",
"object": [
"https://example.com/users/1",
"https://example.com/posts/380590",
"https://example.com/posts/380591"
],
"to": "https://example.com/users/1"
}
Sensitive Extension
Mastodon uses the as:sensitive extension property to mark certain posts as sensitive. When a post is marked as sensitive, any media attached to it will be hidden by default, and if a summary is present, the status content will be collapsed behind this summary. In Mastodon, this is known as a content warning.
Hashtag
Similar to the Mention subtype of Link already defined in ActivityStreams, Mastodon will use Hashtag as a subtype of Link in order to surface posts referencing some common topic identified by a string key. The Hashtag has a name containing the #hashtag microsyntax – a # followed by a string sequence representing a topic. This is similar to the @mention microsyntax, where an @ is followed by some string sequence representing a resource (where in Mastodon’s case, this resource is expected to be an account). Mastodon will also normalize hashtags to be case-insensitive lowercase strings, performing ASCII folding and removing invalid characters.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"Hashtag": "https://www.w3.org/ns/activitystreams#Hashtag"
}
],
"id": "https://example.com/some-post",
"type": "Note",
"attributedTo": "https://example.com",
"content": "I love #cats",
"tag": [
{
"type": "Hashtag",
"name": "#cats",
"href": "https://example.com/tagged/cats"
}
]
}
Custom Emoji
Mastodon supports arbitrary emojis by including a tag of the Emoji type. Handling of custom emojis is similar to handling of mentions and hashtags, where the name of the tagged entity is found as a substring of the natural language properties (name, summary, content) and then linked to the local representation of some resource or topic. In the case of emoji shortcodes, the name is replaced by the HTML for an inline image represented by the icon property (where icon.url links to the image resource).
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"Emoji": "http://joinmastodon.org/ns#Emoji",
}
],
"id": "https://example.com/@alice/hello-world",
"type": "Note",
"content": "Hello world :kappa:",
"tag": [
{
"id": "https://example.com/emoji/123",
"type": "Emoji",
"name": ":kappa:",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/files/kappa.png"
}
}
]
}
Focal Points
Mastodon supports setting a focal point on uploaded images, so that wherever that image is displayed, the focal point stays in view. This is implemented using an extra property focalPoint on Image objects. The property is an array of two floating points between -1.0 and 1.0, with 0,0 being the center of the image, the first value being x (-1.0 is the left edge, +1.0 is the right edge) and the second value being y (-1.0 is the bottom edge, +1.0 is the top edge).
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"focalPoint": {
"@container": "@list",
"@id": "http://joinmastodon.org/ns#focalPoint"
}
}
],
"id": "https://example.com/@alice/hello-world",
"type": "Note",
"content": "A picture attached!",
"attachment": [
{
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/files/cats.png",
"focalPoint": [
-0.55,
0.43
]
}
]
}
Quote Posts
Mastodon implements experimental support for handling remote quote posts according to FEP-044f. Additionally, it understands quoteUri, quoteUrl and _misskey_quote for compatibility.
Should a post contain multiple quotes, Mastodon only accepts the first one.
Furthermore, Mastodon does not handle the full range of interaction policies, but instead converts the authorized followers to a combination of “public”, “followers” and “unknown”, defaulting to “nobody”.
At this time, Mastodon does not offer authoring quotes, nor does it expose a quote policy, or produce stamps for incoming quote requests.
Discoverability Flag
Mastodon allows users to opt-in or opt-out of discoverability features like the profile directory. This flag may also be used as an indicator of the user’s preferences toward being included in external discovery services. If you are implementing such a tool, it is recommended that you respect this property if it is present. This is implemented using an extra property discoverable on objects mapping to profiles.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"discoverable": "http://joinmastodon.org/ns#discoverable"
}
],
"id": "https://mastodon.social/users/Gargron",
"type": "Person",
"discoverable": true
}
Indexable Flag
Mastodon allows users to opt-in or opt-out of indexing features like full-text search of public statuses. If you are implementing such a tool, it is recommended that you respect this property if it is present. This is implemented using an extra property indexable on objects mapping to profiles.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"indexable": "http://joinmastodon.org/ns#indexable"
}
],
"id": "https://mastodon.social/users/Gargron",
"type": "Person",
"indexable": true
}
Suspended Flag
Mastodon reports whether a user was locally suspended, for better handling of these accounts. Suspended accounts in Mastodon return empty data. If a remote account is marked as suspended, it cannot be unsuspended locally. Suspended accounts can be targeted by activities such as Update, Undo, Reject, and Delete. This functionality is implemented using an extra property suspended on objects.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"suspended": "http://joinmastodon.org/ns#suspended"
}
],
"id": "https://example.com/@eve",
"type": "Person",
"suspended": true
}
Memorial Flag
Mastodon reports whether a user’s profile was memorialized, for better handling of these accounts. Memorial accounts in Mastodon return normal data, but are rendered with a header indicating that the account is a memorial account. This functionality is implemented using an extra property memorial on objects.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"memorial": "http://joinmastodon.org/ns#memorial"
}
],
"id": "https://example.com/@alice",
"type": "Person",
"memorial": true
}
Polls
The ActivityStreams Vocabulary specification describes loosely (non-normatively) how a question might be represented. Mastodon’s implementation of polls is somewhat inspired by this section. The following implementation details can be observed:
Question is used as an Object type instead of as an IntransitiveActivity; rather than being sent directly, it is wrapped in a Create just like any other status.
Poll options are serialized using oneOf or anyOf as an array.
Each item in this array has no id, has a type of Note, and has a name representing the text of the poll option.
Each item in this array also has a replies property, representing the responses to this particular poll option. This node has no id, has a type of Collection, and has a totalItems property representing the total number of votes received for this option.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"votersCount": "http://joinmastodon.org/ns#votersCount"
}
],
"id": "https://mastodon.example/users/alice/statuses/1009947848598745",
"type": "Question",
"content": "What should I eat for breakfast today?",
"published": "2023-03-05T07:40:13Z",
"endTime": "2023-03-06T07:40:13Z",
"votersCount": 7,
"anyOf": [
{
"type": "Note",
"name": "apple",
"replies": {
"type": "Collection",
"totalItems": 3
}
},
{
"type": "Note",
"name": "orange",
"replies": {
"type": "Collection",
"totalItems": 7
}
},
{
"type": "Note",
"name": "banana",
"replies": {
"type": "Collection",
"totalItems": 6
}
}
]
}
Poll votes are serialized as Create activities, where the object is a Note with a name that exactly matches the name of the poll option. The Note.inReplyTo points to the URI of the Question object.
For multiple-choice polls, multiple activities may be sent. Votes will be counted if you have not previously voted for that option.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.example/users/bob#votes/827163/activity",
"to": "https://mastodon.example/users/alice",
"actor": "https://mastodon.example/users/bob",
"type": "Create",
"object": {
"id": "https://mastodon.example/users/bob#votes/827163",
"type": "Note",
"name": "orange",
"attributedTo": "https://mastodon.example/users/bob",
"to": "https://mastodon.example/users/alice",
"inReplyTo": "https://mastodon.example/users/alice/statuses/1009947848598745"
}
}
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.example/users/bob#votes/827164/activity",
"to": "https://mastodon.example/users/alice",
"actor": "https://mastodon.example/users/bob",
"type": "Create",
"object": {
"id": "https://mastodon.example/users/bob#votes/827164",
"type": "Note",
"name": "banana",
"attributedTo": "https://mastodon.example/users/bob",
"to": "https://mastodon.example/users/alice",
"inReplyTo": "https://mastodon.example/users/alice/statuses/1009947848598745"
}
}
Mentions
In the ActivityStreams Vocabulary, Mention is a subtype of Link that is intended to represent the microsyntax of @mentions. The tag property is intended to add references to other Objects or Links. For Link tags, the name of the Link should be a substring of the natural language properties (name, summary, content) on that object. Wherever such a substring is found, it can be transformed into a hyperlink reference to the href.
However, Mastodon also uses Mention tags for addressing in some cases. Based on the presence or exclusion of Mention tags, and compared to the explicitly declared audiences in to and cc, Mastodon will calculate a visibility level for the post. Additionally, Mastodon requires Mention tags in order to generate a notification. (The mentioned actor must still be included within to or cc explicitly in order to receive the post.)
public: Public statuses have the as:Public magic collection intounlisted: Unlisted statuses have the as:Public magic collection inccprivate: Followers-only statuses have an actor’s follower collection intoorcc, but do not include theas:Publicmagic collectionlimited: Limited-audience statuses have actors intoorcc, at least one of which is not Mentioned in tagdirect: Mentions-only statuses have actors intoorcc, all of which are Mentioned in tag
Public Key
Public keys are used for HTTP Signatures and Linked Data Signatures. This is implemented using an extra property publicKey on actor objects. See HTTP Signatures for more information.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id": "https://mastodon.social/users/Gargron",
"type": "Person",
"publicKey": {
"id": "https://mastodon.social/users/Gargron#main-key",
"owner": "https://mastodon.social/users/Gargron",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
}
}
Blurhash
Mastodon generates colorful preview thumbnails for attachments. This is implemented using an extra property blurhash on Image objects. The property is a string generated by the BlurHash algorithm.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"blurhash": "http://joinmastodon.org/ns#blurhash"
}
],
"id": "https://example.com/@alice/hello-world",
"type": "Note",
"content": "A picture attached!",
"attachment": [
{
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/files/cats.png",
"blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH"
}
]
}
Featured Collection
What is known in Mastodon as “pinned statuses”, or statuses that are always featured at the top of people’s profiles, is implemented using an extra property featured on the actor object that points to a Collection of objects.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"featured": {
"@id": "http://joinmastodon.org/ns#featured",
"@type": "@id"
}
}
],
"id": "https://example.com/@alice",
"type": "Person",
"featured": "https://example.com/@alice/collections/featured"
}
Featured Tags
Mastodon allows users to feature specific hashtags on their profile for easy browsing, as a discoverability mechanism. This is implemented using an extra property featuredTags on the actor object that points to a Collection of Hashtag objects specifically.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"featuredTags": {
"@id": "http://joinmastodon.org/ns#featuredTags",
"@type": "@id"
}
}
],
"id": "https://example.com/@alice",
"type": "Person",
"featuredTags": "https://example.com/@alice/collections/tags"
}
Profile Metadata
Mastodon supports arbitrary profile fields containing name-value pairs. This is implemented using the attachment property on actor objects, with objects in the array having a type of PropertyValue and a value property, both from the schema.org namespace.
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "https://mastodon.social/users/Gargron",
"type": "Person",
"attachment": [
{
"type": "PropertyValue",
"name": "Patreon",
"value": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span}"
},
{
"type": "PropertyValue",
"name": "Homepage",
"value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span}"
}
]
}
Account Migration
Mastodon uses the Move activity to signal that an account has migrated to a different account. For the migration to be considered valid, Mastodon checks that the new account has defined an alias pointing to the old account (via the alsoKnownAs property).
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.example/users/alice#moves/1",
"actor": "https://mastodon.example/users/alice",
"type": "Move",
"object": "https://mastodon.example/users/alice",
"target": "https://alice.com/users/109835986274379",
"to": "https://mastodon.example/users/alice/followers"
}
Remote Blocking
ActivityPub defines the Block activity for client-to-server (C2S) use-cases, but not for server-to-server (S2S) – it recommends that servers SHOULD NOT deliver Block activities to their object. However, Mastodon will send this activity when a local user blocks a remote user. When Mastodon receives a Block activity where the object is an actor on the local domain, it will interpret this as a signal to hide the actor’s profile and posts from the local user, as well as disallowing mentions of that actor by that local user.
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.example/bd06bb61-01e0-447a-9dc8-95915db9aec8",
"type": "Block",
"actor": "https://mastodon.example/users/alice",
"object": "https://example.com/~mallory",
"to": "https://example.com/~mallory"
}
Secure Mode
Mastodon supports a secure mode (also known as “authorized fetch”) where all cross-server HTTP requests — including GET requests to public resources like actor profiles and statuses — MUST be signed with HTTP Signatures. In normal mode, only POST requests to inboxes require signatures; in secure mode, unsigned GET requests are rejected.
When secure mode is enabled:
- All outgoing
GETrequests for ActivityPub resources are signed using the requesting user’s key, or a server-wide system actor key if no specific user context applies. - All incoming
GETrequests for ActivityPub resources are verified against the requesting actor’s public key. - This forms the foundation for limited federation mode, where the server can restrict which instances are allowed to interact.
Mastic implementation: Since all HTTP traffic flows through the Federation Canister, secure mode can be enforced by requiring HTTP Signature verification on all incoming requests (both
GETandPOST) and signing all outgoing requests. A system actor key pair can be generated at Federation Canister install time for requests without a specific user context.
Follower Synchronization
Mastodon implements a follower synchronization mechanism to detect and correct discrepancies in followers-only post delivery. Over time, follow relationships can become inconsistent between servers (e.g., due to network failures or bugs), causing followers-only posts to be delivered to actors who have unfollowed or missed by actors who have followed.
The mechanism uses a Collection-Synchronization HTTP header on POST requests to remote inboxes:
Collection-Synchronization: collectionId="https://social.example/alice/followers", url="https://social.example/alice/followers_synchronization", digest="sha-256=abcdef..."
The header contains three fields:
collectionId: The sender’s followers collection URI.url: A URL to a partial collection containing the identifiers of followers on the receiving domain. This URL requires a signed request to access.digest: A SHA-256 digest computed by XOR-ing the individual SHA-256 hashes of each follower identifier on the receiving domain.
The receiving server computes its own digest of the followers it believes it has for that actor. If the digests differ, the receiving server fetches the partial collection URL to reconcile the follow state.
HTTP Signatures
HTTP Signatures are used to authenticate requests between servers (S2S) part of the Federation Protocol.
In particular, in the Public data for each user there is a publicKey property that contains the public key of the user. This public key is used to verify the signature of the request.
When a user is created on the server, the server generates and stores securely the private key of the user.
When the server sends an activity to the inbox of another server, the request MUST be signed with the private key of the user.
The server receiving the request MUST verify the signature using the public key of the user.
Mastic implementation: In Mastic, each User Canister generates an RSA key pair at creation time. The private key is stored securely within the User Canister and is used by the Federation Canister to sign outgoing HTTP requests on behalf of the user. The public key is served by the Federation Canister when the actor profile is requested by a remote instance (e.g. to verify a signature).
For any HTTP request incoming to Mastodon for the Federation Protocol, the Signature header MUST be present and contain the signature of the request:
Signature: keyId="https://my.example.com/username#main-key",headers="(request-target) host date",signature="Y2FiYW...IxNGRiZDk4ZA=="
The three parts of the Signature: header can be broken down like so:
Signature:
keyId="https://my.example.com/username#main-key",
headers="(request-target) host date",
signature="Y2FiYW...IxNGRiZDk4ZA=="
The keyId should correspond to the actor and the key being used to generate the signature, whose value is equal to all parameters in headers concatenated together and signed by the key, then Base64-encoded. See Public key for more information on actor keys.
An example key looks like this:
{
"publicKey": {
"id": "https://my.example.com/username#main-key",
"owner": "https://my.example.com/username",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
}
}
Signing POST requests
When making a POST request to Mastodon, you must calculate the RSA-SHA256 digest hash of your request’s body and include this hash (in base64 encoding) within the Digest: header. The Digest: header must also be included within the headers parameter of the Signature: header. For example:
POST /users/username/inbox HTTP/1.1
HOST: mastodon.example
Date: 18 Dec 2019 10:08:46 GMT
Digest: sha-256=hcK0GZB1BM4R0eenYrj9clYBuyXs/lemt5iWRYmIX0A=
Signature: keyId="https://my.example.com/actor#main-key",headers="(request-target) host date digest",signature="Y2FiYW...IxNGRiZDk4ZA=="
Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
{
"@context": "https://www.w3.org/ns/activitystreams",
"actor": "https://my.example.com/actor",
"type": "Create",
"object": {
"type": "Note",
"content": "Hello!"
},
"to": "https://mastodon.example/users/username"
}
Verifying Signatures
Mastodon verifies the signature using the following algorithm:
- Split Signature: into its separate parameters.
- Construct the signature string from the value of headers.
- Fetch the
keyIdand resolve to an actor’spublicKey. - RSA-SHA256 hash the signature string and compare to the Base64-decoded signature as decrypted by
publicKey[publicKeyPem]. - Use the
Date:header to check that the signed request was made within the past12 hours.
WebFinger
For fully-featured Mastodon support, Mastic also implements the WebFinger protocol, which is used to discover information about users and their profiles on the Fediverse. WebFinger is a protocol that allows clients to discover information about a user based on their account name or email address.
WebFinger Simple Flow
Suppose we want to lookup the user @Gargron hosted on the mastodon.social website.
Just make a request to that domain’s /.well-known/webfinger endpoint, with the resource query parameter set to an acct: URI (e.g. acct:veeso_dev@hachyderm.io).
For instance: https://hachyderm.io/.well-known/webfinger?resource=acct%3Aveeso_dev%40hachyderm.io
{
"subject": "acct:veeso_dev@hachyderm.io",
"aliases": [
"https://hachyderm.io/@veeso_dev",
"https://hachyderm.io/users/veeso_dev"
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://hachyderm.io/@veeso_dev"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://hachyderm.io/users/veeso_dev"
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://hachyderm.io/authorize_interaction?uri={uri}"
},
{
"rel": "http://webfinger.net/rel/avatar",
"type": "image/png",
"href": "https://media.hachyderm.io/accounts/avatars/114/410/957/328/747/476/original/1cc6bed1aa3ad81e.png"
}
]
}
Specs
Format and validation specifications for Mastic data types.
- Handle Validation — username format rules and reserved handles
- Hashtag Validation — tag format rules and sanitization
- Media Attachments — MIME, blurhash, and alt-text rules
- Profile Metadata — custom profile field limits
- Reports — moderation report column rules
- Snowflake IDs — 64-bit unique ID generation scheme
Handle Validation
This document defines the validation rules for user handles in Mastic. These rules follow the Mastodon username conventions to ensure compatibility with the broader fediverse.
Local Handle Format
A local handle is the username portion of a Mastic account (e.g. alice in @alice@mastic.social).
| Rule | Value |
|---|---|
| Allowed characters | a-z, 0-9, _ |
| Minimum length | 1 |
| Maximum length | 30 |
| Case sensitivity | Case-insensitive |
| Storage | Stored as lowercase |
Regex: ^[a-z0-9_]{1,30}$
Underscores are allowed in any position, including leading, trailing, and consecutive.
Local handles are intentionally restricted to ASCII. Mastic owns the
local namespace and the users.handle column carries a unique index
that must remain free of Unicode normalization ambiguity (NFC vs NFD,
confusables, IDN homograph). Users on other fediverse instances may
have Unicode handles — see the next section.
Remote Handle Format
A remote handle identifies a user on another fediverse instance
(e.g. @bob@mastodon.social, @user@ꩰ.com). Remote handles are
discovered via WebFinger (RFC 7033)
using the acct: URI scheme defined by
RFC 7565, where both the
userpart and the host are UTF-8.
To remain federation-compatible with Mastodon, Pleroma, Misskey, GoToSocial, and other ActivityPub implementations — many of which allow Unicode usernames and IDN domains — Mastic accepts a broader character set for remote handles.
Userpart
| Rule | Value |
|---|---|
| Allowed characters | Unicode letters, Unicode numbers, _, ., - |
| Minimum length | 1 |
| Maximum length | 64 Unicode scalar values |
| Case sensitivity | Compared case-insensitively (Unicode-aware fold) |
Pattern (PCRE-style): ^[\p{L}\p{N}_.\-]{1,64}$.
Punctuation other than _, ., - is rejected. Whitespace and control
characters are rejected.
Host (domain)
The host is treated as an
Internationalized Domain Name
(IDN). Unicode domains such as ꩰ.com are accepted; for storage and
comparison the host is normalized to its ASCII Compatible Encoding
(Punycode, UTS #46).
| Rule | Value |
|---|---|
| Input form | U-label (Unicode) or A-label (Punycode/ACE) |
| Stored form | A-label (lowercase, ASCII) |
| Maximum length | 253 octets in A-label form (DNS limit) |
Storage
Remote handles are stored in canonical form
<unicode_userpart>@<ace_domain>, with the userpart Unicode-lowercased
and the domain in lowercase A-label form. Two handles compare equal iff
their canonical forms are byte-equal.
Implementation note. Mastic does not yet perform IDN normalization at runtime; remote handles arriving with Unicode hosts are accepted but stored as received. Punycode normalization will be wired into the federation canister alongside WebFinger lookup (tracked separately).
Reserved Handles
The following handles are reserved and cannot be claimed during sign-up. These match system routes and well-known service names commonly reserved across fediverse implementations.
| Handle | Reason |
|---|---|
admin | System role |
administrator | System role |
autoconfig | Service discovery |
autodiscover | Service discovery |
help | System route |
hostmaster | Service role |
info | System route |
mailer-daemon | Email service |
postmaster | Email service |
root | System role |
ssladmin | Certificate admin |
support | System route |
webmaster | Service role |
Hashtag Validation
This document defines the validation rules for hashtags in Mastic. These rules follow the Mastodon hashtag conventions to ensure compatibility with the broader fediverse.
Hashtags are stored in two User Canister tables — hashtags (local
per-user index) and featured_tags (profile-featured list) — and are
materialized into the status_hashtags join table when a status is
published. A future Hashtag Canister (WI-1.28) will aggregate hashtags
across the instance.
Tag Format
A tag is the text portion of a hashtag, stripped of the leading #.
For example, the hashtag #rust has tag rust.
| Rule | Value |
|---|---|
| Allowed characters | Unicode letters, Unicode numbers, _ |
| Minimum length | 1 |
| Maximum length | 30 Unicode scalar values |
| Case sensitivity | Case-insensitive (Unicode-aware lowercasing) |
| Storage | Stored as lowercase, no # |
Pattern (PCRE-style): ^[\p{L}\p{N}_]{1,30}$, with the additional
constraint that no character may be in uppercase form after sanitization.
Underscores are allowed in any position, including leading, trailing, and consecutive. Cased Unicode letters (Latin, Greek, Cyrillic, etc.) are folded to lowercase by the sanitizer; non-cased scripts (Han, Arabic, Myanmar, etc.) are accepted as-is.
Hyphens (-), dots (.), whitespace, punctuation, and emoji are not
allowed.
Unicode normalization
Mastic does not currently apply Unicode Normalization Form C (NFC) to
incoming tags. Producers SHOULD send tags in NFC form. Two visually
identical tags that differ only in normalization (e.g. precomposed é
vs e + COMBINING ACUTE) will be treated as distinct values until
normalization is added (tracked separately).
Sanitization
Input is sanitized before validation:
- Leading and trailing whitespace is trimmed.
- The string is lowercased.
- A single leading
#, if present, is stripped.
# characters in any other position are not stripped and will cause
validation to fail.
Examples
| Input | Sanitized | Valid |
|---|---|---|
rust | rust | yes |
Rust | rust | yes |
#Rust | rust | yes |
web3 | web3 | yes |
rust_lang | rust_lang | yes |
汉字 | 汉字 | yes (Han) |
Café | café | yes |
Ελληνικά | ελληνικά | yes (Greek) |
rust-lang | rust-lang | no (hyphen) |
#rust#2 | rust#2 | no (# in middle) |
🦀 | 🦀 | no (emoji) |
| `` (empty) | `` | no (too short) |
a × 31 | a × 31 | no (too long) |
Implementation
The rules are enforced by HashtagSanitizer and HashtagValidator in
the db-utils crate. Both are attached to every Text column holding
a tag (hashtags.tag, featured_tags.tag). status_hashtags does not
store a tag string directly — it references hashtags.id — so no
validator is needed there.
Media Attachments
This document defines the validation rules for the media table in the
User Canister. Limits follow the
Mastodon defaults to ensure
compatibility with the broader fediverse.
media_type
MIME type of the attachment. Enforced by MimeValidator in db-utils.
| Rule | Value |
|---|---|
| Format | type/subtype per RFC 6838 |
| Slash count | Exactly one / |
| Allowed chars | Lowercase ASCII graphic (!..=~ minus uppercase) |
| Whitespace | Rejected |
| Maximum length | 127 bytes |
| Nullable | No |
Examples accepted: image/png, image/jpeg, video/mp4,
application/vnd.mastic.v1+json.
Examples rejected: Image/png (uppercase), image /png (whitespace),
image/png/x (extra slash), imagepng (no slash).
description
Alt-text for the attachment.
| Rule | Value |
|---|---|
| Sanitization | Trim leading/trailing whitespace |
| Maximum length | 1500 characters |
| Minimum length | 1 (empty string rejected) |
| Nullable | Yes |
| Length unit | Unicode scalar values |
Enforced by TrimSanitizer + BoundedTextValidator(1500) in db-utils.
blurhash
Compact blurhash preview string. Enforced by
BlurhashValidator in db-utils.
| Rule | Value |
|---|---|
| Alphabet | Base83: 0-9, A-Z, a-z, `#$%*+,-.:;=?@[]^_{ |
| Minimum length | 6 bytes |
| Maximum length | 128 bytes |
| Nullable | Yes |
Blurhash length is a function of the encoded component count. The
allowed range covers every valid componentsX × componentsY pairing
with a reasonable safety margin on the upper bound to prevent storage
blow-up.
bytes
Raw media payload (BLOB). No schema-level validation; size and
content-type enforcement happen at the upload-endpoint layer. See
WI-1.16 (#59) and WI-1.17 (#67) for chunked upload handling.
status_id
Foreign key to statuses.id. Enforced by the storage layer.
Profile Metadata
The profile_metadata table stores up to four custom key/value rows
that are shown on the user’s profile. This document defines the
validation rules for the name and value columns. Limits follow the
Mastodon defaults.
position
| Rule | Value |
|---|---|
| Type | UINT8 |
| Range | 0..=3 (primary key) |
The table is capped at four rows via the primary key range. Enforcement of the upper bound is done by the application layer that writes to the table (WI-1.27).
name / value
Both columns share the same rules.
| Rule | Value |
|---|---|
| Sanitization | Trim leading/trailing whitespace |
| Maximum length | 255 characters |
| Minimum length | 1 (empty string rejected) |
| Nullable | No |
| Length unit | Unicode scalar values |
Enforced by TrimSanitizer + BoundedTextValidator(255) in db-utils.
Reports
The reports table in the Directory Canister stores moderation reports
submitted by users. This document defines the validation rules for
report columns.
reason
Free-form text supplied by the reporter.
| Rule | Value |
|---|---|
| Sanitization | Trim leading/trailing whitespace |
| Maximum length | 1000 characters |
| Minimum length | 1 (empty string rejected) |
| Nullable | No |
| Length unit | Unicode scalar values |
Enforced by TrimSanitizer + BoundedTextValidator(1000) in db-utils.
target_status_uri
Optional URI of the specific status being reported. When null, the report targets the user’s account as a whole.
| Rule | Value |
|---|---|
| Format | Valid URL (per the url crate) |
| Nullable | Yes |
Enforced by NullableUrlValidator in db-utils.
state
Open → submitted, awaiting moderator review.
Resolved → moderator took action.
Dismissed → moderator reviewed and declined to act.
See ReportState
in the database schema reference for the on-disk encoding.
reporter, target_canister, resolved_by
Typed Principal columns. Validated by the candid/type layer; no
schema-level validator.
created_at, resolved_at
UINT64 timestamps. created_at is indexed for recent-first
listing. resolved_at is null until state transitions out of Open.
Snowflake IDs
Mastic uses Snowflake IDs as unique identifiers for statuses and other user-generated entities. A Snowflake ID is a 64-bit unsigned integer that encodes a creation timestamp, making IDs roughly sortable by time without requiring a secondary index.
Bit Layout
A Mastic Snowflake ID is a u64 with the following structure:
| Bits | Width | Field | Description |
|---|---|---|---|
| 63-16 | 48 | Timestamp | Milliseconds since the Mastic epoch |
| 15-0 | 16 | Sequence | Per-millisecond monotonic counter (0-65 535) |
Total: 48 + 16 = 64 bits.
Epoch
The Mastic epoch is 2026-01-01T00:00:00Z (Unix timestamp 1 767 225 600 000 ms).
With 48 bits of millisecond precision the timestamp space covers approximately 8 919 years, which is more than sufficient even when measured from the Unix epoch (1970). The custom Mastic epoch is therefore not strictly necessary for correctness, but subtracting it produces smaller numeric values in the early years of the platform, resulting in shorter IDs when serialised as decimal strings in URLs and JSON-LD payloads.
Generation
Each User Canister maintains its own Snowflake generator with:
last_timestamp_ms: the timestamp of the last generated ID.sequence: a 16-bit counter, reset to 0 wheneverlast_timestamp_msadvances.
Algorithm
1. current_ms = ic_cdk::api::time() / 1_000_000 (nanoseconds to milliseconds)
2. timestamp = current_ms - MASTIC_EPOCH_MS
3. if timestamp == last_timestamp_ms:
sequence += 1
if sequence > 0xFFFF:
trap("Snowflake sequence overflow")
else:
sequence = 0
last_timestamp_ms = timestamp
4. id = (timestamp << 16) | sequence
Properties
- Uniqueness: guaranteed within a single canister because the sequence counter prevents collisions within the same millisecond, and the IC provides monotonic time.
- Global uniqueness: achieved because the full ActivityPub
idis a URL that includes the user handle, e.g.https://{domain}/users/{handle}/statuses/{snowflake}. - Sortability: IDs are monotonically increasing within a canister and roughly chronologically ordered across canisters.
No Worker Bits
Unlike the original Twitter Snowflake or Mastodon’s variant, Mastic does not include worker/node bits. Each User Canister is the sole generator of its own IDs, so there is no risk of cross-worker collision. This simplifies the layout and maximises the sequence space.
Representation
- On-chain (Candid):
nat64 - Over ActivityPub (JSON-LD): decimal string, e.g.
"116301527915219032" - In URLs: decimal, e.g.
/users/alice/statuses/116301527915219032
Status Content
This document defines the validation rules for status fields in Mastic. Limits follow the Mastodon defaults to ensure compatibility with the broader fediverse.
content
| Rule | Value |
|---|---|
| Maximum length | 500 characters |
| Minimum length | 1 (empty statuses denied) |
| Encoding | UTF-8 |
| Length unit | Unicode scalar values |
Statuses whose content exceeds 500 characters are rejected with the
ContentTooLong error variant defined in PublishStatusError.
spoiler_text
Optional content warning / spoiler text shown by clients before the
status body. Applies to both statuses.spoiler_text and
edit_history.previous_spoiler_text.
| Rule | Value |
|---|---|
| Sanitization | Trim leading/trailing whitespace |
| Maximum length | 500 characters |
| Minimum length | 1 (empty string rejected) |
| Nullable | Yes |
| Length unit | Unicode scalar values |
Enforced by TrimSanitizer + BoundedTextValidator(500) in db-utils.
in_reply_to_uri
URI of the status this one replies to. Threads are resolved by looking up this URI against local statuses and remote inbox activities.
| Rule | Value |
|---|---|
| Format | Valid URL (per the url crate) |
| Nullable | Yes |
Enforced by NullableUrlValidator in db-utils.
sensitive
Boolean flag. Clients are expected to hide media and content behind a
“show more” gate when sensitive = true, even if spoiler_text is
null. No validation beyond type.
Both spoiler_text and sensitive are part of the Status
candid record returned by feed-rendering queries. When a status is
boosted, the booster’s User Canister inserts a wrapper row in its own
statuses table that denormalizes these fields from the original
status (resolved through Federation.fetch_status), so the boosted
content warning carries through into the booster’s outbox copy and
into followers’ inboxes without any extra cross-canister read at feed
render time.
edited_at
Nullable Uint64 timestamp. Written by the edit flow; never set by
clients directly. No validation beyond type.
Canonical URL Patterns
All ActivityPub resource URLs in Mastic are built from the instance
public_url and the user’s handle. These patterns are centralized in
the User Canister’s domain::urls module to guarantee consistency
across the codebase.
URL Table
| Pattern | Purpose |
|---|---|
{public_url}/users/{handle} | Actor URI (profile) |
{public_url}/users/{handle}/inbox | ActivityPub inbox |
{public_url}/users/{handle}/outbox | ActivityPub outbox |
{public_url}/users/{handle}/followers | Followers collection |
{public_url}/users/{handle}/following | Following collection |
{public_url}/users/{handle}/statuses/{id} | Status URL |
When a user boosts a status, the wrapper status URL
<actor>/statuses/<snowflake> is also the canonical id of the
emitted Announce activity. The booster’s Boost row, wrapper
Status, FeedEntry, and the Announce activity all share a single
Snowflake — one URL dereferences both the wrapper status and the boost
activity.
Example
With public_url = "https://mastic.social" and handle = "alice":
- Actor URI:
https://mastic.social/users/alice - Inbox:
https://mastic.social/users/alice/inbox - Outbox:
https://mastic.social/users/alice/outbox - Followers:
https://mastic.social/users/alice/followers - Following:
https://mastic.social/users/alice/following
Public URL Propagation
The public_url is configured at deploy time on the Federation Canister
and the Directory Canister. When the Directory Canister creates a new
User Canister during sign-up, it passes public_url in the init args.
Each User Canister stores it in settings and uses it via the
domain::urls module.
Deploy ──► Federation Canister (public_url in init args)
──► Directory Canister (public_url in init args)
│
▼ sign_up
User Canister (public_url passed in init args, stored in settings)
Interface
This directory contains the Candid interface definitions for the various canisters in the Mastic project.
-
service : (DirectoryInstallArgs) -> { add_moderator : (AddModeratorArgs) -> (AddModeratorResponse); delete_profile : () -> (DeleteProfileResponse); get_user : (GetUserArgs) -> (GetUserResponse) query; remove_moderator : (RemoveModeratorArgs) -> (RemoveModeratorResponse); search_profiles : (SearchProfilesArgs) -> (SearchProfilesResponse) query; sign_up : (text) -> (SignUpResponse); suspend : (SuspendArgs) -> (SuspendResponse); user_canister : (opt Principal) -> (UserCanisterResponse) query; whoami : () -> (WhoAmIResponse) query } -
service : (FederationInstallArgs) -> { http_request : (HttpRequest) -> (HttpResponse) query; http_request_update : (HttpRequest) -> (HttpResponse); send_activity : (SendActivityArgs) -> (SendActivityResponse) } -
service : (UserInstallArgs) -> { accept_follow : (AcceptFollowArgs) -> (AcceptFollowResponse); block_user : (BlockUserArgs) -> (BlockUserResponse); boost_status : (BoostStatusArgs) -> (BoostStatusResponse); delete_profile : () -> (DeleteProfileResponse); delete_status : (DeleteStatusArgs) -> (DeleteStatusResponse); follow_user : (FollowUserArgs) -> (FollowUserResponse); get_follow_requests : (GetFollowRequestsArgs) -> (GetFollowRequestsResponse) query; get_followers : (GetFollowersArgs) -> (GetFollowersResponse) query; get_following : (GetFollowingArgs) -> (GetFollowingResponse) query; get_liked : (GetLikedArgs) -> (GetLikedResponse) query; get_profile : () -> (GetProfileResponse) query; get_statuses : (GetStatusesArgs) -> (GetStatusesResponse) composite_query; like_status : (LikeStatusArgs) -> (LikeStatusResponse); publish_status : (PublishStatusArgs) -> (PublishStatusResponse); read_feed : (ReadFeedArgs) -> (ReadFeedResponse) query; receive_activity : (ReceiveActivityArgs) -> (ReceiveActivityResponse); reject_follow : (RejectFollowArgs) -> (RejectFollowResponse); undo_boost : (UndoBoostArgs) -> (UndoBoostResponse); unfollow_user : (UnfollowUserArgs) -> (UnfollowUserResponse); unlike_status : (UnlikeStatusArgs) -> (UnlikeStatusResponse); update_profile : (UpdateProfileArgs) -> (UpdateProfileResponse) }
Candid Types
This document defines all shared Candid types used across the Directory, Federation, and User canisters.
Common Types
Visibility
Controls the audience of a status post. Maps to ActivityPub addressing:
- Public: visible to everyone, appears in public timelines.
- Unlisted: visible to everyone via direct link, but excluded from public timelines.
- FollowersOnly: visible only to the author’s followers.
- Direct: visible only to explicitly mentioned users.
type Visibility = variant {
Public;
Unlisted;
FollowersOnly;
Direct;
};
UserProfile
A user’s public profile information. Stored in the User Canister and returned by profile queries.
| Field | Description |
|---|---|
handle | Unique username chosen at sign-up (e.g. alice). |
display_name | Optional human-readable name shown in the UI. |
bio | Optional free-text biography. |
avatar_url | Optional URL pointing to the user’s avatar image. |
created_at | Timestamp (nanoseconds since epoch) of account creation. |
type UserProfile = record {
handle : text;
display_name : opt text;
bio : opt text;
avatar_url : opt text;
created_at : nat64;
};
Status
A single post authored by a user. Each status has a unique ID, content body, author principal, creation timestamp, and visibility setting.
| Field | Description |
|---|---|
id | Snowflake identifier of the status assigned by the User Canister. |
content | The text content of the post. |
author | ActivityPub actor URI of the status author. |
created_at | Timestamp (milliseconds since epoch) when the status was created. |
visibility | Audience control for this status (see Visibility). |
like_count | Cached count of Like activities received for this status. |
boost_count | Cached count of Announce (boost) activities received. |
spoiler_text | Optional content warning / spoiler text shown before the content. |
sensitive | If true, clients should hide the content behind a CW by default. |
type Status = record {
id : nat64;
content : text;
author : text;
created_at : nat64;
visibility : Visibility;
like_count : nat64;
boost_count : nat64;
spoiler_text : opt text;
sensitive : bool;
};
When a status is boosted, the booster’s User Canister inserts a wrapper
row in its own statuses table that denormalizes the original
content, spoiler_text, and sensitive fields. Feed rendering reads
the wrapper directly so the boosted CW carries through into the
booster’s outbox copy.
FeedItem
A single entry in a user’s feed. Wraps a Status and optionally indicates
that it was boosted (reblogged) by another user. Also surfaces per-viewer
interaction flags so clients can render “you liked this” / “you boosted
this” markers without an additional round-trip.
| Field | Description |
|---|---|
status | The status being displayed. |
boosted_by | If present, the actor URI of the user who boosted this status into the feed. |
liked | true if the viewing user has liked the underlying status. |
boosted | true if the viewing user has boosted the underlying status. Always true for the viewer’s own boost wrapper rows. |
type FeedItem = record {
status : Status;
boosted_by : opt text;
liked : bool;
boosted : bool;
};
Directory Canister Types
DirectoryInstallArgs
Install arguments for the Directory Canister. Uses the Init/Upgrade
variant pattern required by IC canister lifecycle.
- Init: provided on first install. Sets the initial moderator, the principal of the Federation Canister this directory cooperates with, and the public URL of the instance.
- Upgrade: provided on subsequent upgrades (currently empty).
type DirectoryInstallArgs = variant {
Init : record {
initial_moderator : principal;
federation_canister : principal;
public_url : text;
};
Upgrade : record {};
};
SignUp
Response, request and error types for the sign_up method. Registers a new user in the
directory, creating a User Canister and mapping the caller’s principal to the
chosen handle.
- AlreadyRegistered: the caller already has an account.
- HandleTaken: the requested handle is in use by another user.
- InvalidHandle: the handle does not meet validation rules (e.g. length, allowed characters).
- AnonymousPrincipal: anonymous users are not allowed to sign up.
- InternalError: an unexpected internal error occurred.
type SignUpRequest = record {
handle : text;
};
type SignUpResponse = variant {
Ok;
Err : SignUpError;
};
type SignUpError = variant {
AlreadyRegistered;
HandleTaken;
InvalidHandle;
AnonymousPrincipal;
InternalError : text;
};
RetrySignUp
Response and error types for the retry_sign_up method. Retries canister
creation for a user whose canister creation failed during the sign-up process.
- NotRegistered: the caller has no account to retry.
- CanisterNotInFailedState: the caller’s canister is not in a failed state, so retrying is not allowed.
- InternalError: an unexpected internal error occurred.
type RetrySignUpResponse = variant {
Ok;
Err : RetrySignUpError;
};
type RetrySignUpError = variant {
NotRegistered;
CanisterNotInFailedState;
InternalError : text;
};
UserCanisterStatus
The lifecycle state of a user’s canister. Used in the WhoAmI response and as the eligibility filter for SearchProfiles.
- Active: the canister is created and operational.
- CreationPending: canister creation is in progress.
- CreationFailed: canister creation failed; the user may retry via
retry_sign_up. - DeletionPending: the user has requested account deletion and the canister is being torn down asynchronously.
- Suspended: a moderator has suspended the user; the canister exists but is hidden from public discovery.
type UserCanisterStatus = variant {
Active;
CreationPending;
CreationFailed;
DeletionPending;
Suspended;
};
WhoAmI
Response and error types for the who_am_i method. Returns the caller’s handle,
User Canister ID, and canister status, allowing a logged-in user to discover
their own identity and check canister readiness.
| Field | Description |
|---|---|
handle | The caller’s registered handle. |
user_canister | Principal of the caller’s User Canister. (Optional) |
canister_status | Status of the caller’s User Canister (see UserCanisterStatus). |
- NotRegistered: the caller has no account in the directory.
type WhoAmI = record {
handle : text;
user_canister : principal;
canister_status : UserCanisterStatus;
};
type WhoAmIResponse = variant {
Ok : WhoAmI;
Err : WhoAmIError;
};
type WhoAmIError = variant {
NotRegistered;
};
UserCanister
Response and error types for the user_canister method. Resolves the caller’s
principal to their User Canister ID.
- NotRegistered: the caller has no account in the directory.
type UserCanisterResponse = variant {
Ok : principal;
Err : UserCanisterError;
};
type UserCanisterError = variant {
NotRegistered;
};
GetUser
Request, response, and error types for the get_user method. Looks up a user
by handle and returns their handle and User Canister ID.
| Field | Description |
|---|---|
handle | The handle to look up. |
canister_id | Principal of the looked-up user’s canister. |
- NotFound: no user exists with the given handle.
type GetUserArgs = record {
handle : text;
};
type GetUser = record {
handle : text;
canister_id : principal;
};
type GetUserResponse = variant {
Ok : GetUser;
Err : GetUserError;
};
type GetUserError = variant {
NotFound;
};
AddModerator
Request, response, and error types for the add_moderator method. Grants
moderator privileges to a principal. Only existing moderators may call this.
| Field | Description |
|---|---|
principal | The principal to promote to moderator. |
- Unauthorized: the caller is not a moderator.
- AlreadyModerator: the target principal is already a moderator.
type AddModeratorArgs = record {
principal : principal;
};
type AddModeratorResponse = variant {
Ok;
Err : AddModeratorError;
};
type AddModeratorError = variant {
Unauthorized;
AlreadyModerator;
};
RemoveModerator
Request, response, and error types for the remove_moderator method. Revokes
moderator privileges from a principal. Only existing moderators may call this.
| Field | Description |
|---|---|
principal | The principal to demote. |
- Unauthorized: the caller is not a moderator.
- NotModerator: the target principal is not currently a moderator.
type RemoveModeratorArgs = record {
principal : principal;
};
type RemoveModeratorResponse = variant {
Ok;
Err : RemoveModeratorError;
};
type RemoveModeratorError = variant {
Unauthorized;
NotModerator;
};
Suspend
Request, response, and error types for the suspend method. Suspends a user
account, preventing further activity. Only moderators may call this.
| Field | Description |
|---|---|
principal | The principal of the user to suspend. |
- Unauthorized: the caller is not a moderator.
- NotFound: no user exists with the given principal.
type SuspendArgs = record {
principal : principal;
};
type SuspendResponse = variant {
Ok;
Err : SuspendError;
};
type SuspendError = variant {
Unauthorized;
NotFound;
};
SearchProfiles
Request, response, and error types for the search_profiles query. Searches
registered user handles by case-insensitive substring match with pagination.
The query is sanitized before matching: a leading @ is stripped,
whitespace is trimmed, and the string is lowercased — the same pipeline
applied to handles on insert. So @Alice, alice, and ALICE all
match the handle alice.
Only users with canister_status = Active and a non-null canister_id
are returned. CreationPending, CreationFailed, DeletionPending, and
Suspended users are excluded by construction.
An empty query returns all eligible users, paginated.
| Field | Description |
|---|---|
query | Free-text search string matched against handles. |
offset | Number of results to skip (for pagination). |
limit | Maximum results to return; must be in 1..=50. |
Each result entry contains:
| Field | Description |
|---|---|
handle | The matched user’s handle. |
canister_id | Principal of the matched user’s canister. |
Errors:
- BadArgs: the request was rejected (
limit == 0,limit > 50, or the sanitized query failed handle validation). In practice the canister traps on these inputs at message-inspect time; the variant is reserved for parity with other endpoints. - Internal: an internal storage error occurred while running the query.
type SearchProfilesArgs = record {
query : text;
offset : nat64;
limit : nat64;
};
type SearchProfileEntry = record {
handle : text;
canister_id : principal;
};
type SearchProfilesResponse = variant {
Ok : vec SearchProfileEntry;
Err : SearchProfilesError;
};
type SearchProfilesError = variant {
BadArgs;
Internal : text;
};
DeleteProfile (Directory)
Response and error types for the delete_profile method on the Directory
Canister. Removes the caller’s account and handle mapping from the directory.
- NotRegistered: the caller has no account to delete.
type DeleteProfileResponse = variant {
Ok;
Err : DeleteProfileError;
};
type DeleteProfileError = variant {
NotRegistered;
};
User Canister Types
UserInstallArgs
Install arguments for the User Canister. Uses the Init/Upgrade variant
pattern required by IC canister lifecycle.
- Init: provided on first install. Sets the owner principal (the user’s Internet Identity), the Federation Canister principal used for outbound ActivityPub delivery, the user handle, and the instance public URL.
- Upgrade: provided on subsequent upgrades (currently empty).
type UserInstallArgs = variant {
Init : record {
owner : principal;
federation_canister : principal;
handle : text;
public_url : text;
};
Upgrade : record {};
};
GetProfile
Response and error types for the get_profile method. Returns the full
UserProfile for this canister’s owner.
- NotFound: the profile has not been initialized yet.
type GetProfileResponse = variant {
Ok : UserProfile;
Err : GetProfileError;
};
type GetProfileError = variant {
NotFound;
};
UpdateProfile
Request, response, and error types for the update_profile method. Updates
the caller’s profile fields. Only the canister owner may call this. All fields
are optional; only provided fields are updated.
| Field | Description |
|---|---|
display_name | New display name, or null to leave unchanged. |
bio | New biography, or null to leave unchanged. |
avatar_url | New avatar URL, or null to leave unchanged. |
- Unauthorized: the caller is not the canister owner.
type UpdateProfileArgs = record {
display_name : opt text;
bio : opt text;
avatar_url : opt text;
};
type UpdateProfileResponse = variant {
Ok;
Err : UpdateProfileError;
};
type UpdateProfileError = variant {
Unauthorized;
};
FollowUser
Request, response, and error types for the follow_user method. Sends a
follow request to another user by handle.
| Field | Description |
|---|---|
handle | Handle of the user to follow. |
- Unauthorized: the caller is not the canister owner.
- AlreadyFollowing: the caller already follows the target user.
- CannotFollowSelf: the caller attempted to follow themselves.
- Internal: an internal error occurred while processing the request.
type FollowUserArgs = record {
handle : text;
};
type FollowUserResponse = variant {
Ok;
Err : FollowUserError;
};
type FollowUserError = variant {
Unauthorized;
AlreadyFollowing;
CannotFollowSelf;
Internal : text;
};
AcceptFollow
Request, response, and error types for the accept_follow method. Accepts a
pending follow request from another user, adding them to the followers list.
| Field | Description |
|---|---|
follower | Principal of the User Canister whose follow request to accept. |
- Unauthorized: the caller is not the canister owner.
- RequestNotFound: no pending follow request exists from the given principal.
type AcceptFollowArgs = record {
follower : principal;
};
type AcceptFollowResponse = variant {
Ok;
Err : AcceptFollowError;
};
type AcceptFollowError = variant {
Unauthorized;
RequestNotFound;
};
RejectFollow
Request, response, and error types for the reject_follow method. Rejects a
pending follow request from another user.
| Field | Description |
|---|---|
follower | Principal of the User Canister whose follow request to reject. |
- Unauthorized: the caller is not the canister owner.
- RequestNotFound: no pending follow request exists from the given principal.
type RejectFollowArgs = record {
follower : principal;
};
type RejectFollowResponse = variant {
Ok;
Err : RejectFollowError;
};
type RejectFollowError = variant {
Unauthorized;
RequestNotFound;
};
UnfollowUser
Request, response, and error types for the unfollow_user method. Removes the
caller from the target user’s followers list and removes the target from the
caller’s following list.
| Field | Description |
|---|---|
canister_id | Principal of the User Canister to unfollow. |
- Unauthorized: the caller is not the canister owner.
- NotFollowing: the caller does not currently follow the target user.
type UnfollowUserArgs = record {
canister_id : principal;
};
type UnfollowUserResponse = variant {
Ok;
Err : UnfollowUserError;
};
type UnfollowUserError = variant {
Unauthorized;
NotFollowing;
};
BlockUser
Request, response, and error types for the block_user method. Blocks another
user, preventing them from following or interacting with the caller.
| Field | Description |
|---|---|
canister_id | Principal of the User Canister to block. |
- Unauthorized: the caller is not the canister owner.
type BlockUserArgs = record {
canister_id : principal;
};
type BlockUserResponse = variant {
Ok;
Err : BlockUserError;
};
type BlockUserError = variant {
Unauthorized;
};
GetFollowers
Request, response, and error types for the get_followers method. Returns a
paginated list of actor URIs that follow this user. The limit must not exceed
50 (the maximum page size).
| Field | Description |
|---|---|
offset | Number of results to skip (for pagination). |
limit | Maximum number of results to return (max 50). |
- LimitExceeded: the requested
limitexceeds the maximum page size (50). - Internal: an internal error occurred while querying followers.
type GetFollowersArgs = record {
offset : nat64;
limit : nat64;
};
type GetFollowersResponse = variant {
Ok : vec text;
Err : GetFollowersError;
};
type GetFollowersError = variant {
LimitExceeded;
Internal : text;
};
GetFollowing
Request, response, and error types for the get_following method. Returns a
paginated list of actor URIs that this user follows. The limit must not exceed
50 (the maximum page size).
| Field | Description |
|---|---|
offset | Number of results to skip (for pagination). |
limit | Maximum number of results to return (max 50). |
- LimitExceeded: the requested
limitexceeds the maximum page size (50). - Internal: an internal error occurred while querying the following list.
type GetFollowingArgs = record {
offset : nat64;
limit : nat64;
};
type GetFollowingResponse = variant {
Ok : vec text;
Err : GetFollowingError;
};
type GetFollowingError = variant {
LimitExceeded;
Internal : text;
};
PublishStatus
Request, response, and error types for the publish_status method. Creates a
new status post in the caller’s outbox and distributes it via the Federation
Canister. For Public, Unlisted, and FollowersOnly visibilities, the
recipients are the author’s followers. For Direct visibility, the recipients
are the explicitly listed mentions — followers are not addressed.
| Field | Description |
|---|---|
content | The text content of the new post. |
visibility | Audience control for this status (see Visibility). |
mentions | Actor URIs explicitly mentioned. Required (non-empty) when visibility is Direct. |
On success, returns the created Status with its assigned ID and timestamp.
- Unauthorized: the caller is not the canister owner.
- ContentEmpty: the content is empty or contains only whitespace.
- ContentTooLong: the content exceeds the maximum allowed length.
- NoRecipients: a
Directstatus was published with an emptymentionslist. - Internal: an internal error occurred while publishing the status.
type PublishStatusArgs = record {
content : text;
visibility : Visibility;
mentions : vec text;
};
type PublishStatusResponse = variant {
Ok : Status;
Err : PublishStatusError;
};
type PublishStatusError = variant {
Unauthorized;
ContentEmpty;
ContentTooLong;
NoRecipients;
Internal : text;
};
DeleteStatus
Request, response, and error types for the delete_status method. Removes a
status authored by the canister owner. The cascade drops the row from
statuses (which propagates via FK to media, boosts, edit_history,
status_hashtags, pinned_statuses), the matching feed entry, and any
liked row that defensively references the same URI. A Delete(Note)
activity is emitted to followers (excluding blocked actors) so receivers
can purge their cached inbox + feed entries.
| Field | Description |
|---|---|
status_uri | Canonical URI of the status to delete. |
- NotFound: no status exists with the given URI on this canister.
- InvalidUri: the URI does not match the
…/statuses/{id}shape. - Internal: an unexpected error occurred during the delete or dispatch.
type DeleteStatusArgs = record {
status_uri : text;
};
type DeleteStatusResponse = variant {
Ok;
Err : DeleteStatusError;
};
type DeleteStatusError = variant {
NotFound;
InvalidUri;
Internal : text;
};
LikeStatus
Request, response, and error types for the like_status method. Records a
like on a status authored by another user.
like_status is idempotent: calling it for a status the caller has
already liked returns Ok without recording a duplicate row in the
liked collection and without re-emitting a Like activity. Only the
caller (canister owner) is authorized; non-owner calls are rejected at
the inspect layer.
| Field | Description |
|---|---|
status_url | ActivityPub URI of the status to like. |
- Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type LikeStatusArgs = record {
status_url : text;
};
type LikeStatusResponse = variant {
Ok;
Err : LikeStatusError;
};
type LikeStatusError = variant {
Internal : text;
};
UnlikeStatus
Request, response, and error types for the undo_like method. Removes a
previously recorded like from a status.
| Field | Description |
|---|---|
status_url | ActivityPub URI of the status to unlike. |
- Unauthorized: the caller is not the canister owner.
- NotFound: no like exists for the given status.
type UnlikeStatusArgs = record {
status_url : text;
};
type UnlikeStatusResponse = variant {
Ok;
Err : UnlikeStatusError;
};
type UnlikeStatusError = variant {
Unauthorized;
NotFound;
};
BoostStatus
Request, response, and error types for the boost_status method. Boosts
(reblogs) a status authored by another user, sharing it with the caller’s
followers.
The flow resolves the original status through the Federation Canister’s
fetch_status endpoint (which dereferences local
authors via get_local_status), then inserts a
wrapper Status row, a boosts row, and a feed outbox entry that
all share a single Snowflake — also reused as the emitted Announce
activity id.
boost_status is idempotent: calling it for a status the caller has
already boosted returns Ok without recording a duplicate row in the
boosts collection and without re-emitting an Announce activity. Only the
caller (canister owner) is authorized; non-owner calls are rejected at the
inspect layer.
| Field | Description |
|---|---|
status_url | ActivityPub URI of the status to boost. |
- Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type BoostStatusArgs = record {
status_url : text;
};
type BoostStatusResponse = variant {
Ok;
Err : BoostStatusError;
};
type BoostStatusError = variant {
Internal : text;
};
UndoBoost
Request, response, and error types for the undo_boost method. Removes a
previously recorded boost from a status.
undo_boost is idempotent: calling it for a status the caller has
not boosted (or has already un-boosted) returns Ok without emitting a
second Undo(Announce) activity. Only the caller (canister owner) is
authorized; non-owner calls are rejected at the inspect layer.
| Field | Description |
|---|---|
status_url | ActivityPub URI of the status to un-boost. |
- Internal: an unexpected internal error occurred (database access failure, federation dispatch failure, etc.).
type UndoBoostArgs = record {
status_url : text;
};
type UndoBoostResponse = variant {
Ok;
Err : UndoBoostError;
};
type UndoBoostError = variant {
Internal : text;
};
GetLocalStatus
Request, response, and error types for the get_local_status query.
Returns a single Status owned by this User Canister. Used by the
Federation Canister’s fetch_status endpoint to
dereference a local actor’s status.
get_local_status is a public query with no inspect block. The caller’s
principal determines the visibility scope:
- Owner principal: can read any status regardless of visibility.
- Federation Canister principal with
requester_actor_uri = Some(uri): returnsPublicandUnlistedalways; returnsFollowersOnlyonly ifuriis in the followers list; returnsNotFoundforDirect. - Federation Canister principal with
requester_actor_uri = None: returnsPublicandUnlistedonly. - Anonymous / other principals: returns
PublicandUnlistedonly.
| Field | Description |
|---|---|
id | Snowflake ID of the status to read. |
requester_actor_uri | Actor URI of the requester (used for visibility filtering). |
- NotFound: no status with the given ID is visible to the caller.
- Internal: an unexpected internal error occurred.
type GetLocalStatusArgs = record {
id : nat64;
requester_actor_uri : opt text;
};
type GetLocalStatusResponse = variant {
Ok : Status;
Err : GetLocalStatusError;
};
type GetLocalStatusError = variant {
NotFound;
Internal : text;
};
GetLiked
Request, response, and error types for the get_liked method. Returns a
paginated list of status IDs that the caller has liked.
| Field | Description |
|---|---|
offset | Number of results to skip (for pagination). |
limit | Maximum number of results to return. |
- Unauthorized: the caller is not the canister owner.
type GetLikedArgs = record {
offset : nat64;
limit : nat64;
};
type GetLikedResponse = variant {
Ok : vec text;
Err : GetLikedError;
};
type GetLikedError = variant {
Unauthorized;
};
ReadFeed
Request, response, and error types for the read_feed method. Returns a
paginated list of feed items from the caller’s home timeline, including
statuses from followed users and boosted content.
| Field | Description |
|---|---|
offset | Number of results to skip (for pagination). |
limit | Maximum number of results to return. |
- Unauthorized: the caller is not the canister owner.
type ReadFeedArgs = record {
offset : nat64;
limit : nat64;
};
type ReadFeedResponse = variant {
Ok : vec FeedItem;
Err : ReadFeedError;
};
type ReadFeedError = variant {
Unauthorized;
};
ReceiveActivity
Request, response, and error types for the receive_activity method. Called by
the Federation Canister to deliver an incoming ActivityPub activity (encoded as
JSON) to this User Canister’s inbox.
| Field | Description |
|---|---|
activity_json | JSON-encoded ActivityPub activity object. |
- Unauthorized: the caller is not the Federation Canister.
- InvalidActivity: the JSON could not be parsed as a valid ActivityPub activity.
- ProcessingFailed: the activity was valid but could not be processed (e.g. references a non-existent status).
type ReceiveActivityArgs = record {
activity_json : text;
};
type ReceiveActivityResponse = variant {
Ok;
Err : ReceiveActivityError;
};
type ReceiveActivityError = variant {
Unauthorized;
InvalidActivity;
ProcessingFailed;
};
Federation Canister Types
FederationInstallArgs
Install arguments for the Federation Canister. Uses the Init/Upgrade
variant pattern required by IC canister lifecycle.
- Init: provided on first install. Sets the Directory Canister principal (used to resolve handles to User Canisters) and the public URL used for constructing ActivityPub actor URIs and WebFinger responses.
- Upgrade: provided on subsequent upgrades (currently empty).
type FederationInstallArgs = variant {
Init : record {
directory_canister : principal;
public_url : text;
};
Upgrade : record {};
};
SendActivity
Request, response, and error types for the send_activity method. Called by a
registered User Canister to deliver an outbound ActivityPub activity. Supports
a single activity (One) or a batch (Batch) per call. Local targets are
routed to the recipient User Canister via the Directory Canister; remote
targets are skipped (remote HTTP delivery is Milestone 2).
| Field | Description |
|---|---|
activity_json | JSON-encoded ActivityPub activity object to send. |
target_inbox | URL of the actor’s inbox to deliver the activity to. |
SendActivityError:
- InvalidTargetInbox(text):
target_inboxURL failed to parse or has an unexpected path shape. - UnknownLocalUser(text): local inbox references a handle that is not registered in the Directory Canister.
- DeliveryFailed(text): inter-canister call to the target User Canister failed (transport or decode).
- Rejected(text): target User Canister accepted the call but rejected the activity.
type SendActivityArgsObject = record {
activity_json : text;
target_inbox : text;
};
type SendActivityArgs = variant {
One : SendActivityArgsObject;
Batch : vec SendActivityArgsObject;
};
type SendActivityResult = variant {
Ok;
Err : SendActivityError;
};
type SendActivityResponse = variant {
One : SendActivityResult;
Batch : vec SendActivityResult;
};
type SendActivityError = variant {
InvalidTargetInbox : text;
UnknownLocalUser : text;
DeliveryFailed : text;
Rejected : text;
};
FetchStatus
Request, response, and error types for the fetch_status method. Called
by a User Canister to dereference a status by URI through the
Federation Canister. The Federation Canister parses the URI, resolves
the local handle via the Directory Canister, and forwards the request
to the target User Canister’s get_local_status
query.
In Milestone 1, only local URIs (host equal to the instance
public_url) are supported. Remote URIs return Unsupported;
Milestone 3 will add HTTPS outcalls for cross-instance fetches.
| Field | Description |
|---|---|
uri | ActivityPub status URI to dereference. |
requester_actor_uri | Actor URI of the requester (forwarded for visibility scoping). |
- InvalidUri: the URI failed to parse or did not match the expected
<public_url>/users/<handle>/statuses/<id>shape. - Unsupported: the URI host is not local (Milestone 1 limitation).
- NotFound: the URI is local but no matching handle / status exists, or the status is not visible to the requester.
- Internal: an unexpected internal error occurred.
type FetchStatusArgs = record {
uri : text;
requester_actor_uri : opt text;
};
type FetchStatusResponse = variant {
Ok : Status;
Err : FetchStatusError;
};
type FetchStatusError = variant {
InvalidUri;
Unsupported;
NotFound;
Internal : text;
};
Mastic
Unleashing the power of IC on the Fediverse
Introduction
Mastic aims to bring the Fediverse — a decentralised network of interconnected social platforms — natively onto the Internet Computer ecosystem.
The Fediverse operates through the ActivityPub protocol, enabling independent instances of the social platform on the network to communicate with one another, thereby creating a distributed social network.
The Mastic project brings the possibility both to have a node dedicated to the Internet Computer community that can finally have a dedicated fully decentralised social network, but also any people that wish to set up their own Mastic node on the Internet Computer, empowering the decentralisation of the Fediverse, bringing web3 functionalities on it, and with the ease the IC brings in spinning up network services.
Mission
Our mission is to build a fully federated, ActivityPub-compatible social platform running entirely on the Internet Computer protocol, enabling seamless participation in the Fediverse.
This project aims to bring a truly decentralised social experience to the IC ecosystem by integrating with an already thriving network of over 1.4 million monthly active users across the Fediverse.
In particular, it will help engage the vibrant community of Rust developers, many of whom are active on Mastodon, by bridging their preferred social environment with the technical and philosophical foundations of the Internet Computer.
Impact
Mastic has the potential to reshape the future of federated social platforms by demonstrating how a modern, scalable, and fully on-chain implementation of ActivityPub can be achieved using Rust and the Internet Computer ecosystem.
While Mastodon has played a central role in popularising the Fediverse, its current architecture — based on Ruby on Rails and multiple supporting services (PostgreSQL, Redis, Sidekiq, Nginx, etc.) — presents significant barriers to contribution and scalability.
The complexity of deploying and maintaining Mastodon discourages smaller communities and individual developers, and its ageing technology stack makes it less attractive to modern developers, particularly those in the Web3 and Rust ecosystems.
By reimagining an ActivityPub-compatible server as a set of autonomous, composable canisters written in Rust, this project removes those barriers: no external infrastructure, no ops burden, and a far more approachable codebase for developers already aligned with the values of decentralisation.
Mastic can not only attract a new wave of Rust and Web3 contributors to the Internet Computer but also act as a reference architecture for building scalable, open social infrastructure in the post-cloud era.
Additionally, this would finally bring authentication to the Internet Identity on the Fediverse.
Governance & Sustainability Model
Mastic is not just about a technical implementation of a decentralised social platform - it is a long-term vision for how open social platforms can be sustainably governed and evolved without relying on centralised control or ad revenue models.
To ensure sustainability and a decentralised governance model, Mastic will be deployed under a **Service Nervous System ** (SNS), making Mastic a fully on-chain DAO governed by its community.
DAO-Based Governance
We propose giving the DAO community governance for upgrades, feature proposals, federation decisions and moderation policies.
Token holders will be able to vote on proposals and allocate funds to the treasury.
Participants in the DAO will be able to apply for moderation and will be elected by the community.
Market Analysis
Total Addressable Market
Currently, Mastodon has 1.4 million active users per month.
Most of these are developers, journalists, artists and niche communities. In particular, Mastodon has experienced significant growth following Elon Musk’s acquisition of Twitter.
Of course, some users have left Twitter but have not joined Mastodon, either because they are using BlueSky or other platforms, such as Reddit or Discord groups.
Serviceable Available Market
Above 10-15% of the TAM
Our first target is:
- Rust Developers: 2.3 million developers in 2024
- Web3 Developers: about 25k developers in 2024
- IC developers: ~3k
Serviceable Obtainable Market
A realistic target within the first year could be around 20,000 users, if we primarily focus on developers and web3 developers.
Differentiation from Traditional Mastodon Instances
Mastic introduces a fundamentally new paradigm for federated social platforms by reimagining the Mastodon experience as a fully on-chain, modular architecture built natively on the Internet Computer. While Mastodon has pioneered the Fediverse movement, its traditional deployment model — based on a monolithic Ruby on Rails stack supported by PostgreSQL, Redis, Sidekiq, and other infrastructure — imposes significant operational overhead and limits scalability.
By contrast, Mastic offers the following distinct advantages:
- Fully on-chain architecture: Every component of Mastic runs within canisters on the Internet Computer, eliminating the need for traditional DevOps, hosting, and external databases.
- Modularity and scalability: Each user operates within their User Canister, ensuring composability, privacy, and scalability by design.
- Internet Identity integration: Users can sign in with Internet Identity, bringing native IC authentication to the Fediverse.
- Decentralised governance: Instead of relying on a central instance admin, Mastic is governed by a DAO through the Service Nervous System (SNS), enabling transparent, community-driven decisions around moderation and feature development.
- Developer-friendly technology stack: Mastic is built entirely in Rust, offering a modern, secure, and performant alternative that resonates with a growing community of Rust and Web3 developers.
These innovations make Mastic not only more accessible for developers and small communities but also more aligned with the philosophical foundations of the decentralised web.
User Acquisition Strategy
Mastic’s adoption strategy is designed to activate key communities aligned with its technical foundation and decentralised vision, and to establish a sustainable user base through targeted outreach and integrations within the Web3 ecosystem.
Our multi-phase growth approach includes:
- Developer-first onboarding: We will bootstrap the platform by targeting Rust and Internet Computer developers, communities that already value decentralisation, composability, and performance. Early access, bounties, and contributor incentives will be designed to attract this group.
- Fediverse-native advocacy: As a fully compatible ActivityPub implementation, Mastic will be promoted within the Fediverse itself. We aim to attract privacy-conscious users and instance operators looking for a lower-maintenance, modern alternative to traditional Mastodon deployments.
Architecture Overview
The architecture of Mastic consists of 3 components, each of which will be a standalone canister.
Frontend
The frontend of Mastic provides an interface where each user can sign in to Mastic and interact with the Fediverse by publishing Statuses and interacting with other users, including those from the Mastodon Fediverse on the web2. The User will be able to sign in with Internet Identity on the frontend and interact with the backend canisters using Candid.
User Canister
Each user has one User Canister. The User canister is created by the Directory Canister via the IC management canister every time a user signs up on Mastic, and is deleted whenever they delete their account or migrate to another Fediverse instance. The User Canister provides the interface for users to interact with the Fediverse through the Inbox and Outbox, implementing the core functionality of the ActivityPub protocol.
The User canister will use wasm-dbms to store data inside of a relational database.
Directory Canister
The Directory Canister provides an index for all existing users on Mastic by creating a Map between a user’s identity and their handle (e.g., @veeso@mastic.social) and their User Canister instance.
Federation Canister
The Federation canister implements the HTTP Web server to handle both incoming and outgoing requests of the Federation Protocol[1], which is used to communicate with the other instances on the Fediverse. The Federation canister MUST also implement the WebFinger[2] protocol to search for users.
Authorization Model
Mastic uses principal-based authorization, where each canister checks the caller’s principal against an expected set of principals configured at install time via init args.
- User → User Canister: The caller’s principal must match the owner principal that was set when the User Canister was installed. This ensures only the canister owner can call methods like publish_status, update_profile, like_status, etc.
- Federation Canister → User Canister: The User Canister stores the Federation Canister’s principal at install time (passed via UserInstallArgs). Only the Federation Canister can call receive_activity to deliver incoming activities from the Fediverse.
- User Canister → Federation Canister: The Federation Canister stores the Directory Canister’s principal at install time. The Directory Canister registers each new User Canister principal with the Federation Canister, so the Federation Canister maintains a list of all authorised User Canister principals that can call send_activity.
- Directory Canister: Moderator actions (add_moderator, remove_moderator, suspend) require the caller’s principal to be present in the moderator list. The initial moderator is set at install time via DirectoryInstallArgs.
Interface
Here’s a description of the Candid interface of the Directory, Federation and User canisters. Types are not yet defined, but only calls are provided to provide an overview of the flows that will need to be implemented. For more information on the flows, see the User Stories.
Directory Interface
service : (DirectoryInstallArgs) -> {
add_moderator : (AddModeratorArgs) -> (AddModeratorResponse);
delete_profile : () -> (DeleteProfileResponse);
get_user : (GetUserArgs) -> (GetUserResponse) query;
remove_moderator : (RemoveModeratorArgs) -> (RemoveModeratorResponse);
retry_delete_profile : () -> (RetryDeleteProfileResponse);
retry_sign_up : () -> (RetrySignUpResponse);
search_profiles : (SearchProfilesArgs) -> (SearchProfilesResponse) query;
sign_up : (text) -> (SignUpResponse);
suspend : (SuspendArgs) -> (SuspendResponse);
user_canister : (opt Principal) -> (UserCanisterResponse) query;
whoami : () -> (WhoAmIResponse) query
}
Federation Interface
service : (FederationInstallArgs) -> {
http_request : (HttpRequest) -> (HttpResponse) query;
http_request_update : (HttpRequest) -> (HttpResponse);
fetch_status : (FetchStatusArgs) -> (FetchStatusResponse);
send_activity : (SendActivityArgs) -> (SendActivityResponse)
}
User Interface
service : (UserInstallArgs) -> {
accept_follow : (AcceptFollowArgs) -> (AcceptFollowResponse);
block_user : (BlockUserArgs) -> (BlockUserResponse);
boost_status : (BoostStatusArgs) -> (BoostStatusResponse);
delete_status : (DeleteStatusArgs) -> (DeleteStatusResponse);
emit_delete_profile_activity : () -> (EmitDeleteProfileActivityResponse);
follow_user : (FollowUserArgs) -> (FollowUserResponse);
get_follow_requests : (GetFollowRequestsArgs) -> (GetFollowRequestsResponse) query;
get_followers : (GetFollowersArgs) -> (GetFollowersResponse) query;
get_following : (GetFollowingArgs) -> (GetFollowingResponse) query;
get_liked : (GetLikedArgs) -> (GetLikedResponse) query;
get_local_status : (GetLocalStatusArgs) -> (GetLocalStatusResponse) query;
get_profile : () -> (GetProfileResponse) query;
like_status : (LikeStatusArgs) -> (LikeStatusResponse);
publish_status : (PublishStatusArgs) -> (PublishStatusResponse);
read_feed : (ReadFeedArgs) -> (ReadFeedResponse) query;
receive_activity : (ReceiveActivityArgs) -> (ReceiveActivityResponse);
reject_follow : (RejectFollowArgs) -> (RejectFollowResponse);
undo_boost : (UndoBoostArgs) -> (UndoBoostResponse);
undo_like : (UnlikeStatusArgs) -> (UnlikeStatusResponse);
unfollow_user : (UnfollowUserArgs) -> (UnfollowUserResponse);
update_profile : (UpdateProfileArgs) -> (UpdateProfileResponse)
}
User Stories
UC1: As a User, I should be able to create a Profile
- Alice lands on Mastic Frontend
- Alice signs in with her Internet Identity
- Alice sends a sign_up call
- The Directory Canister establishes a connection between Alice’s identity and her handle
- The Directory Canister starts a worker to create her User Canister
- The Directory Canister creates Alice’s User Canister via the IC management canister
- The Directory Canister installs Alice’s User Canister via the IC management canister
- The Directory Canister stores Alice’s User Canister ID for Alice’s Identity
- Alice queries her User Canister ID with user_canister
- The Directory Canister returns the ID to Alice
UC2: As a User, I should be able to Sign In
- Alice lands on Mastic Frontend
- Alice signs in with her Internet Identity
- Alice queries the Directory Canister to get her user_canister with whoami
- The Directory Canister returns her User Canister’s principal
UC3: As a User, I should be able to update my Profile
- Alice signs in with her Internet Identity
- Alice queries the Directory Canister with whoami to obtain her User Canister principal
- Alice calls update_profile on her User Canister with the updated fields (display name, bio, avatar, etc.)
- The User Canister persists the changes
- The User Canister sends an Update activity to the Federation Canister so that remote followers receive the updated profile
UC4: As a User, I should be able to delete my Profile
- Alice signs in with her Internet Identity
- Alice calls delete_profile on the Directory Canister
- The Directory Canister creates a tombstone for Alice and starts a delete worker
- The Directory Canister notifies Alice’s User Canister, which aggregates a Delete activity for all of Alice’s followers
- The User Canister sends the Delete activity to the Federation Canister
- The Federation Canister buffers the activity data, then forwards it to all remote followers
- The Directory Canister deletes Alice’s User Canister via the IC management canister
UC5: As a User, I should be able to follow another Profile
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls follow_user on her User Canister with the target user’s handle
- If the target is local: the User Canister resolves the handle via the Directory Canister, then sends a Follow activity to the target User Canister through the Federation Canister
- If the target is remote: the User Canister sends a Follow activity to the Federation Canister, which forwards it to the remote instance via HTTP
- The target user’s User Canister stores the incoming Follow as a pending follow request
- The target user can view pending requests via get_follow_requests and call accept_follow or reject_follow
- When the target accepts, an Accept activity is delivered back, and Alice’s User Canister records the follow relationship
UC6: As a User, I should be able to remove a Following
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls unfollow_user on her User Canister with the target user’s handle
- The User Canister removes the follow relationship locally
- The User Canister sends an Undo(Follow) activity through the Federation Canister to notify the target (local or remote) that Alice has unfollowed them
UC7: As a User, I should be able to see a user’s profile
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls get_user on the Directory Canister with the target handle
- The Directory Canister returns the target user’s User Canister principal
- Alice calls get_profile on the target User Canister to retrieve their public profile information
UC8: As a User, I should be able to search for other users
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls search_profiles on the Directory Canister with a search query
- The Directory Canister returns a list of matching user handles and their User Canister principals
UC9: As a User, I should be able to create a Status
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls publish_status on her User Canister with the status content
- The User Canister stores the status in Alice’s outbox
- The User Canister aggregates a Create(Note) activity for each follower of Alice
- The User Canister sends the activities to the Federation Canister
- The Federation Canister routes local activities through the Directory Canister to each local follower’s User Canister inbox
- The Federation Canister forwards remote activities via HTTP to external Fediverse instances
UC10: As a User, I should be able to like a Status
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls like_status on her User Canister with the status URL
- The User Canister records the like in Alice’s liked collection
- The User Canister sends a Like activity to the Federation Canister
- The Federation Canister routes the activity to the status author’s User Canister (local) or forwards it via HTTP (remote)
- like_status is idempotent: if Alice has already liked the status, the call returns
Okwithout inserting a duplicate or re-sending the Like activity
UC11: As a User, I should be able to boost a Status
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls boost_status on her User Canister with the status ID
- The User Canister records the boost in Alice’s outbox
- The User Canister sends an Announce activity to the Federation Canister
- The Federation Canister routes the activity to the status author and Alice’s followers, handling both local delivery (through the Directory Canister) and remote forwarding via HTTP
UC12: As a User, I should be able to read my Feed
- Alice signs in and obtains her User Canister principal via the Directory Canister
- Alice calls read_feed on her User Canister with pagination parameters
- The User Canister aggregates the feed from Alice’s inbox (statuses from followed users) and outbox (Alice’s own statuses)
- The User Canister returns the paginated feed to Alice
UC13: As a User, I should be able to receive updates from users I follow on other Fediverse instances
- Bob publishes a status on a remote Mastodon instance
- The remote instance dispatches a Create(Note) activity to the Federation Canister via HTTP
- The Federation Canister verifies the HTTP Signature and resolves the target user via the Directory Canister
- The Federation Canister calls receive_activity on Alice’s User Canister to deliver the status to her inbox
- Alice reads her feed and sees Bob’s status
UC14: As a User on a Web2 Mastodon Instance, I should be able to receive updates and interact with users on Mastic
- Alice publishes a status on Mastic
- The Federation Canister forwards the Create(Note) activity via HTTP to Bob’s remote Mastodon instance
- Bob sees Alice’s status in his feed
- Bob likes or replies to Alice’s status
- The remote instance sends the corresponding activity (Like, Create(Note) reply) to the Federation Canister via HTTP
- The Federation Canister routes the activity to Alice’s User Canister
UC15: As a Moderator, I should be able to remove a Status violating the policy
- A Moderator signs in with their Internet Identity
- The Moderator identifies a status that violates the instance policy
- The Moderator calls delete_status on the offending User Canister (authorised by the moderator principal stored in the Directory Canister)
- The User Canister removes the status from the user’s outbox
- The User Canister sends a Delete activity through the Federation Canister to notify followers
UC16: As a Moderator, I should be able to suspend a Profile violating the policy
- A Moderator signs in with their Internet Identity
- The Moderator calls suspend on the Directory Canister with the offending user’s handle
- The Directory Canister marks the user as suspended, preventing further API calls
- The Directory Canister notifies the User Canister, which sends a Delete activity through the Federation Canister to notify remote followers that the account is no longer active
UC17: As a Controller, I should be able to upgrade all User Canisters
- The Controller (deployer pre-SNS, SNS governance post-SNS) calls upgrade_user_canisters on the Directory Canister with the new WASM blob
- The Directory Canister stores the WASM and starts a timer-based scheduler
- The scheduler upgrades user canisters in batches, retrying failures up to 5 times
- The Controller can query get_upgrade_status to monitor progress
- Once all canisters are processed, the upgrade is marked as completed
UC18: As the System, sign-up spam should be prevented via a cycles fee
- Alice calls sign_up on the Directory Canister
- The Directory Canister checks that Alice attached enough cycles to cover the canister creation fee plus initial cycles
- If insufficient cycles are attached, the call is rejected with an InsufficientCycles error
- If sufficient, the Directory Canister accepts the cycles and proceeds with the sign-up flow
UC19: As the System, action spam should be prevented via rate limiting
- Alice performs mutating social actions on her User Canister (post, follow, like, boost, block, etc.)
- The User Canister enforces a rate limit of 20 actions per minute using a sliding window
- If Alice exceeds the limit, the call is rejected with a RateLimitExceeded error
- The rate limit resets on canister upgrade
UC20: As a User, I should be able to reply to a Status
- Alice views a Status in her feed or on another user’s profile
- Alice composes a reply, specifying the status she is replying to
- The User Canister creates a new status with an
in_reply_toreference to the original - A
Create(Note)activity withinReplyTois sent to the Federation Canister - The original author and Alice’s followers receive the reply in their inboxes
- Anyone viewing the original status can see the reply thread
UC21: As a User, I should be able to attach media to a Status
- Alice composes a new status and attaches one or more media files (images, videos, or audio)
- The User Canister stores each media file as a blob in the
mediatable, linked to the status via a foreign key - The status is published with attachment metadata (media type, description, blurhash)
- Followers receive the status with attachment references in their inboxes
- Anyone viewing the status can retrieve the attached media
Milestones
These are the milestones we plan to achieve during the first year of Mastic’s development cycle.
Milestone 0 - Proof of concept
Duration: 1.5 months
First implementation to demo the social platform.
Only basic functionalities are implemented, such as signing up, posting statuses, and reading the feed.
User stories:
- UC1
- UC2
- UC9
- UC5
- UC12
- UC7
Milestone 1 - Standalone Mastic Node
Duration: 3 months
Mastic is set up; we build all the data structures required by the user to sign up, operate on statuses (Create, Delete, like, and Boost), and find and follow other users on the Mastic node.
This Milestone won’t include the integration with the Fediverse yet, but it will provide an already usable Social Network, ready to be integrated onto the Fediverse soon.
These stories must be implemented during this phase:
- UC3
- UC4
- UC6
- UC8
- UC10
- UC11
- UC15
- UC16
- UC17
- UC18
- UC19
- UC20
- UC21
Milestone 2 - Frontend
Duration: 2 months
Build the Mastic web frontend as a React application deployed as an IC asset canister. The frontend provides Internet Identity authentication and a Mastodon-like interface covering all user stories from Milestones 0 and 1.
These stories must be implemented during this phase:
- UC1
- UC2
- UC3
- UC4
- UC5
- UC6
- UC7
- UC8
- UC9
- UC10
- UC11
- UC12
- UC15
- UC16
- UC20
- UC21
Milestone 3 - Integrating the Fediverse
Duration: 2 months
This step requires implementing the Federation Canister using the Federation Protocol.
This will allow Mastic to be fully integrated with the Fediverse ecosystem.
These user stories must be implemented during this phase:
- UC13
- UC14
Milestone 4 - SNS Launch
Duration: 1 month
During this phase, we plan to launch Mastic on the SNS to add a fully decentralised governance to Mastic.
We need to implement a comprehensive integration for voting, specifically for adding and removing moderators and updating policies.
Reference
- ActivityPub: https://www.w3.org/TR/activitypub/
- ActivityStreams: https://www.w3.org/TR/activitystreams-core/
- Mastodon ActivityPub Spec: https://docs.joinmastodon.org/spec/activitypub/
- ActivityPub Federation framework implemented with Rust: https://docs.rs/activitypub_federation/0.6.5/activitypub_federation/
- Webfinger: https://docs.joinmastodon.org/spec/webfinger/
Flows
This page documents the sequence diagrams for all major Mastic flows. Each flow maps to one or more user stories defined in the project specification.
Create Profile
sequenceDiagram
actor A as Alice
participant II as Internet Identity
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant IC as IC Management Canister
A->>II: Sign In with II
A->>DIR: Create Profile (candid)
DIR->>DIR: Add map between user and handle
DIR->>DIR: Start worker to create user canister
DIR->>IC: create_canister
IC-->>DIR: Canister ID
DIR->>IC: install_code (User Canister WASM)
IC-->>UC: Install Canister
DIR->>DIR: Store User Canister Principal for Alice
A->>DIR: Get user canister Principal
DIR->>A: Principal of User Canister
Sign In
sequenceDiagram
actor A as Alice
participant II as Internet Identity
participant DIR as Directory Canister
A->>II: Sign In with II
A->>DIR: Get User Canister (candid)
DIR->>A: Return Canister ID
Update Profile
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: Update Profile (candid)
UC->>UC: Update Profile in User Canister
Delete Profile
Note: The Federation Canister must buffer the Delete activity data before the User Canister is destroyed, since the User Canister will no longer exist to serve actor profile requests after deletion.
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant IC as IC Management Canister
participant M as Mastodon Web2
A->>DIR: Delete profile (candid)
DIR->>A: Ok
DIR->>DIR: Create tombstone for Alice
DIR->>DIR: Start delete canister worker
DIR->>UC: Notify Delete
UC->>UC: Aggregate notification based on followers
UC->>FED: Send Delete Activity
FED->>FED: Buffer Delete activity data
FED->>DIR: Route Delete to local followers
DIR->>DIR: Resolve local follower User Canisters
FED->>M: Forward Delete Activity to remote followers
UC->>DIR: Activity Sent
DIR->>IC: stop_canister + delete_canister
IC-->>DIR: Canister Deleted
Follow User
The follow lifecycle has three phases: request, pending, and accept/reject. Alice sends a follow request; Bob’s canister stores it as a pending follow request; Bob reviews pending requests and accepts or rejects each one.
Send follow request (local)
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant BUC as Bob's User Canister
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: follow_user (candid)
UC->>UC: Store pending follow (status: Pending)
UC->>FED: Send Follow Activity
FED->>DIR: Resolve Bob's User Canister
DIR->>FED: Bob's User Canister Principal
FED->>BUC: receive_activity (Follow)
BUC->>BUC: Store follow request in follow_requests table
Send follow request (remote)
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant M as Mastodon Web2
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: follow_user (candid)
UC->>UC: Store pending follow (status: Pending)
UC->>FED: Send Follow Activity
FED->>M: Forward Follow Activity (ActivityPub / HTTP Signature)
Accept follow request (local)
sequenceDiagram
actor B as Bob
participant BUC as Bob's User Canister
participant FED as Federation Canister
participant DIR as Directory Canister
participant UC as Alice's User Canister
B->>BUC: get_follow_requests (candid)
BUC->>B: List of pending follow requests
B->>BUC: accept_follow (candid, Alice's actor URI)
BUC->>BUC: Add Alice to followers table
BUC->>BUC: Remove request from follow_requests table
BUC->>FED: Send Accept(Follow) Activity
FED->>DIR: Resolve Alice's User Canister
DIR->>FED: Alice's User Canister Principal
FED->>UC: receive_activity (Accept(Follow))
UC->>UC: Update following status: Accepted
Accept follow request (remote target accepts)
sequenceDiagram
participant M as Mastodon Web2
participant FED as Federation Canister
participant DIR as Directory Canister
participant UC as Alice's User Canister
M->>FED: Send Accept(Follow) Activity (ActivityPub)
FED->>DIR: Resolve Alice's User Canister
DIR->>FED: Alice's User Canister Principal
FED->>UC: receive_activity (Accept(Follow))
UC->>UC: Update following status: Accepted
Reject follow request (local)
sequenceDiagram
actor B as Bob
participant BUC as Bob's User Canister
participant FED as Federation Canister
participant DIR as Directory Canister
participant UC as Alice's User Canister
B->>BUC: get_follow_requests (candid)
BUC->>B: List of pending follow requests
B->>BUC: reject_follow (candid, Alice's actor URI)
BUC->>BUC: Remove request from follow_requests table
BUC->>FED: Send Reject(Follow) Activity
FED->>DIR: Resolve Alice's User Canister
DIR->>FED: Alice's User Canister Principal
FED->>UC: receive_activity (Reject(Follow))
UC->>UC: Remove pending follow entry
Reject follow request (remote target rejects)
sequenceDiagram
participant M as Mastodon Web2
participant FED as Federation Canister
participant DIR as Directory Canister
participant UC as Alice's User Canister
M->>FED: Send Reject(Follow) Activity (ActivityPub)
FED->>DIR: Resolve Alice's User Canister
DIR->>FED: Alice's User Canister Principal
FED->>UC: receive_activity (Reject(Follow))
UC->>UC: Remove pending follow entry
Unfollow User
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant BUC as Bob's User Canister (local)
participant M as Mastodon Web2
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: unfollow_user (candid)
UC->>UC: Remove following (Bob)
UC->>FED: Send Undo(Follow) Activity
alt Bob is local
FED->>DIR: Resolve Bob's User Canister
FED->>BUC: Deliver Undo(Follow) activity
BUC->>BUC: Remove follower (Alice)
else Bob is remote
FED->>M: Forward Undo(Follow) Activity (ActivityPub)
end
Block User
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant BUC as Bob's User Canister (local)
participant M as Mastodon Web2
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: block_user (candid)
UC->>UC: Record block locally
UC->>FED: Send Block Activity
alt Bob is local
FED->>DIR: Resolve Bob's User Canister
FED->>BUC: Deliver Block activity
BUC->>BUC: Hide Alice's profile from Bob
else Bob is remote
FED->>M: Forward Block Activity (ActivityPub)
end
Create Status
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant BUC as Bob's User Canister (local)
participant M as Mastodon Web2
actor B as Bob (remote)
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: Create Status (candid)
UC->>UC: Store Status in Alice's Outbox
UC->>UC: Aggregate Create activity for each follower
UC->>FED: Forward Create Status Activities (ic)
FED->>DIR: Resolve local followers
DIR->>FED: Local follower User Canister principals
FED->>BUC: Deliver Create activity to local follower inboxes
FED->>M: Forward Create activities to remote instances (ActivityPub)
B->>M: Get Feed
M->>B: Return Alice's Status
Like Status
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant TUC as Target User Canister (local)
participant M as Mastodon Web2
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: Like Status (candid)
UC->>UC: Store Like in Alice's Outbox
UC->>FED: Forward Like Activity (ic)
alt Status author is local
FED->>DIR: Resolve author's User Canister
FED->>TUC: Deliver Like activity
else Status author is remote
FED->>M: Forward Like Activity (ActivityPub)
end
Boost Status
sequenceDiagram
actor A as Alice (booster)
participant UC as Booster User Canister
participant FED as Federation Canister
participant DIR as Directory Canister
participant TUC as Target User Canister (author)
participant Fol as Follower User Canisters
A->>UC: boost_status(status_url)
UC->>FED: fetch_status(uri, requester=alice_actor_uri)
FED->>DIR: lookup handle from URI
DIR-->>FED: target canister id
FED->>TUC: get_local_status(id, requester=alice_actor_uri)
TUC-->>FED: Status (visibility-filtered)
FED-->>UC: Status
UC->>UC: tx { wrapper Status, Boost row, FeedEntry } (shared snowflake)
UC->>FED: send_activity(Batch[Announce])
FED->>TUC: receive_activity(Announce) -- bumps boost_count
FED->>Fol: receive_activity(Announce) -- inbox row + feed entry
UC-->>A: Ok
The booster’s User Canister never trusts boost content from its caller:
the wrapper row’s content, spoiler_text, and sensitive are
populated from the Status returned by Federation.fetch_status,
which in turn dereferences the local author through
User.get_local_status (Milestone 1; Milestone 3 will extend the
remote branch via HTTPS outcalls).
A single Snowflake is reused as boosts.id, the wrapper statuses.id,
the feed.id for the booster’s outbox entry, and the Announce
activity id (<own_actor_uri>/statuses/<snowflake>).
boost_status is idempotent: a duplicate boost of the same
status_url returns Ok without inserting a second wrapper or
re-emitting the Announce. undo_boost reverses the flow — it deletes
the boosts row, the wrapper statuses row, and the feed outbox
entry, then dispatches an Undo(Announce) to followers and the
original author. undo_boost is also idempotent.
Remote author / follower delivery via HTTPS is Milestone 3.
Delete Status
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant M as Mastodon Web2
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: Delete Status (candid)
UC->>UC: Remove Status from Alice's Outbox
UC->>FED: Forward Delete Status Activity (ic)
FED->>DIR: Resolve local followers
FED->>FED: Deliver Delete to local follower inboxes
FED->>M: Forward Delete Activity to remote instances (ActivityPub)
Read Feed
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
A->>DIR: Get Alice's User Canister (candid)
DIR->>A: User Canister Principal
A->>UC: Read Feed page (candid)
UC->>UC: Aggregate feed from Alice's Inbox and Outbox
UC->>A: Return Feed
Receive Updates from Fediverse
sequenceDiagram
actor A as Alice
participant UC as Alice's User Canister
participant DIR as Directory Canister
participant FED as Federation Canister
participant M as Mastodon Web2
actor B as Bob
B->>M: Publish Status
M->>M: Get who Follows Bob
M->>FED: Dispatch create Status Activity for Alice
FED->>DIR: Get User Canister for Alice
DIR->>FED: User Canister ID
FED->>UC: Put Status to Alice's Inbox
A->>UC: Read feed
UC->>A: Return Bob's Post
Milestones
Implementation plans for each Mastic development milestone.
- Milestone 0 - Proof of Concept
- Milestone 1 - Standalone Mastic Node
- Milestone 2 - Frontend
- Milestone 3 - Integrating the Fediverse
- Milestone 4 - SNS Launch
Milestone 0 - Proof of Concept
Duration: 1.5 months
Goal: First implementation to demo the social platform with basic functionalities: signing up, posting statuses, following users, and reading the feed.
User Stories: UC1, UC2, UC5, UC7, UC9, UC12
Work Items
WI-0.1: Implement ActivityPub types in the activitypub crate
Description: Implement all ActivityPub and ActivityStreams protocol types in
the activitypub crate (crates/libs/activitypub). This crate provides the
canonical Rust representation of the ActivityPub protocol used by the Federation
Canister for S2S communication and JSON-LD serialization/deserialization.
Types must round-trip correctly through serde_json and match the JSON-LD
payloads documented in docs/src/activitypub.md.
What should be done:
- Core types (
activitypub::object):Object— base type withid,type,content,name,summary,published,updated,url,to,cc,bto,bcc,audience,attributed_to,in_reply_to,source,tag,attachment,replies,likes,shares,sensitiveSource—content+media_typeTombstone—id,type,published,updated,deletedObjectTypeenum —Note,Question,Image,Tombstone, etc.
- Actor types (
activitypub::actor):Actor— extends Object withinbox,outbox,following,followers,liked,preferred_username,public_key,endpoints,manually_approves_followers,discoverable,indexable,suspended,memorial,featured,featured_tags,also_known_as,attribution_domains,icon,imageActorTypeenum —Person,Application,Service,Group,OrganizationPublicKey—id,owner,public_key_pemEndpoints—shared_inbox
- Activity types (
activitypub::activity):Activity— extends Object withactor,object,target,result,origin,instrumentActivityTypeenum —Create,Update,Delete,Follow,Accept,Reject,Like,Announce,Undo,Block,Add,Remove,Flag,Move
- Collection types (
activitypub::collection):Collection—id,type,total_items,first,last,current,itemsOrderedCollection— same as Collection withordered_itemsCollectionPage/OrderedCollectionPage—part_of,next,prev,items/ordered_items
- Link types (
activitypub::link):Link—href,rel,media_type,name,hreflang,height,widthMention— subtype of LinkHashtag— subtype of Link
- Tag types (
activitypub::tag):Tagenum —Mention,Hashtag,EmojiEmoji—id,name,icon(Image withurlandmedia_type)
- Mastodon extensions (
activitypub::mastodon):PropertyValue—name,value(for profile metadata fields)- Poll support on
Questionobjects:end_time,closed,voters_count,one_of/any_ofwithname+replies.total_items - Attachment properties:
blurhash,focal_point
- WebFinger types (
activitypub::webfinger):WebFingerResponse—subject,aliases,linksWebFingerLink—rel,type,href,template
- JSON-LD context (
activitypub::context):- Constants for standard context URIs
(
https://www.w3.org/ns/activitystreams,https://w3id.org/security/v1, Mastodon namespacehttp://joinmastodon.org/ns#) Contexttype for@contextserialization (single URI, array, or map)
- Constants for standard context URIs
(
- All types derive
serde::Serialize,serde::Deserialize,Clone,Debug,PartialEq - Use
#[serde(rename_all = "camelCase")]to match JSON-LD field naming - Use
#[serde(skip_serializing_if = "Option::is_none")]for optional fields - Use
#[serde(rename = "@context")]for the context field
Acceptance Criteria:
- All types compile and are exported from the
activitypubcrate serde_jsonround-trip tests for every top-level type- Deserialization tests using real-world Mastodon JSON-LD payloads from
docs/src/activitypub.mdexamples cargo clippypasses with zero warnings- Unit tests cover: Object, Actor, each ActivityType, Collection, OrderedCollection, CollectionPage, WebFinger, Mention, Hashtag, Emoji
WI-0.2: Define shared Candid types in the did crate
Description: Define all shared Candid types required by Milestone 0 in the
did crate. These types are used across the Directory, Federation, and User
canisters.
What should be done:
- Define
DirectoryInstallArgs(initial moderator principal, federation canister principal) - Define
UserInstallArgs(owner principal, federation canister principal) - Define
FederationInstallArgs(directory canister principal, domain name) - Define sign-up types:
SignUpResponse - Define whoami types:
WhoAmIResponse - Define user canister query types:
UserCanisterResponse - Define get-user types:
GetUserArgs,GetUserResponse - Define profile types:
GetProfileResponse,UserProfile(handle, display name, bio, avatar URL, created at) - Define follow types:
FollowUserArgs,FollowUserResponse,AcceptFollowArgs,AcceptFollowResponse,RejectFollowArgs,RejectFollowResponse - Define status types:
PublishStatusArgs,PublishStatusResponse,Status(id, content, author, created at, visibility) - Define feed types:
ReadFeedArgs,ReadFeedResponse,FeedItem - Define get-followers/following types:
GetFollowersArgs,GetFollowersResponse,GetFollowingArgs,GetFollowingResponse - Define
SendActivityArgs,SendActivityResponsefor the Federation canister - Define
ReceiveActivityArgs,ReceiveActivityResponsefor the User canister - Ensure all types derive
CandidType,Deserialize,Serialize,Clone,Debug
Acceptance Criteria:
- All types compile and are exported from the
didcrate - Types match the
.didinterface files indocs/interface/ cargo clippypasses with zero warnings- Unit tests verify serialization/deserialization round-trips
WI-0.3: Design and implement database schema for Milestone 0
Description: Design the relational database schema using wasm-dbms for
all entities required by Milestone 0 in the Directory and User canisters.
Since wasm-dbms manages its own stable memory, ic-stable-structures cannot
be used alongside it in these canisters. Canister init arguments and runtime
configuration are persisted in a settings key-value table instead.
The Federation Canister does not use wasm-dbms and uses
ic-stable-structures directly (see WI-0.10).
What should be done:
- Add
ic-dbms-canisteras a workspace dependency - Create
crates/libs/db-utilscrate:- Define
SettingKeyas au32newtype with named constants per canister (e.g.,FEDERATION_PRINCIPAL,OWNER_PRINCIPAL,DOMAIN_NAME) - Define
SettingValueenum wrappingic-dbms-canisterValuevariants (Text, Integer, Blob) with typed accessor methods (as_text(),as_principal(), etc.) - Provide helper functions for reading/writing settings rows
- Add the crate to the workspace in root
Cargo.toml
- Define
- Shared
settingstable (both canisters):settingstable:key(INTEGER PK),value(depends on key — TEXT, INTEGER, or BLOB)- Uses
SettingKeyconstants fromdb-utilsto identify entries
- Directory Canister schema:
settingstable — storesfederation_principalfromDirectoryInstallArgs- The initial moderator from
DirectoryInstallArgsis inserted as the first row in themoderatorstable duringinit userstable:principal(PRINCIPAL PK),handle(TEXT UNIQUE NOT NULL),user_canister_id(PRINCIPAL NOT NULL),status(TEXT NOT NULL DEFAULT ‘active’),created_at(INTEGER NOT NULL)moderatorstable:principal(PRINCIPAL PK),added_at(INTEGER NOT NULL)- Index on
users.handlefor fast lookups
- User Canister schema:
settingstable — storesowner_principal,federation_principalfromUserInstallArgsprofiletable (single-row):handle(TEXT NOT NULL),display_name(TEXT),bio(TEXT),avatar_url(TEXT),header_url(TEXT),created_at(INTEGER NOT NULL),updated_at(INTEGER NOT NULL)statusestable:id(TEXT PK),content(TEXT NOT NULL),visibility(TEXT NOT NULL DEFAULT ‘public’),created_at(INTEGER NOT NULL)inboxtable:id(TEXT PK),activity_type(TEXT NOT NULL),actor_uri(TEXT NOT NULL),object_json(TEXT NOT NULL),created_at(INTEGER NOT NULL)followerstable:actor_uri(TEXT PK),created_at(INTEGER NOT NULL)followingtable:actor_uri(TEXT PK),status(TEXT NOT NULL DEFAULT ‘pending’),created_at(INTEGER NOT NULL)- Ed25519 public key is derived at runtime via the IC threshold
Schnorr API (
schnorr_public_key) and cached in memory - Indexes on
statuses.created_at,inbox.created_atfor feed ordering
- Initialize the schema in each canister’s
initfunction and persist init args into thesettingstable - Data survives canister upgrades via
wasm-dbmsstable memory management
Acceptance Criteria:
- All tables are created on canister initialization
- Init args are persisted in the
settingstable and retrievable after upgrade db-utilscrate compiles and is usable from both canisters- Schema supports all queries needed by Milestone 0 work items
- Data persists across canister upgrades
- Unit tests verify table creation and basic CRUD operations
WI-0.4: Implement Directory Canister - sign-up flow
Description: Implement the sign_up method on the Directory Canister,
which creates a new User Canister for the caller and maps their principal to a
handle and canister ID.
What should be done:
-
Use the database schema from WI-0.3 (
users,moderators,settingstables) -
Implement
initto acceptDirectoryInstallArgs, create the schema, and persist init args into thesettingstable -
Implement
sign_up(handle):- Validate handle format (alphanumeric, lowercase, 1-30 chars)
- Check handle uniqueness
- Create a new User Canister via the IC management canister
(
ic_cdk::api::management_canister::main::create_canister) - Install the User Canister WASM via
ic_cdk::api::management_canister::main::install_code - Store the mapping (principal -> handle, canister ID)
- Register the new User Canister with the Federation Canister
- Return
SignUpResponsewith the canister ID Acceptance Criteria:
-
Calling
sign_upwith a valid handle creates a User Canister and returns its principal -
Duplicate handles are rejected
-
Duplicate sign-ups from the same principal are rejected
-
Invalid handles are rejected with a descriptive error
-
The user record is persisted across canister upgrades
-
Integration test: sign up, then verify the canister exists and is callable
WI-0.5: Implement Directory Canister - query methods
Description: Implement the read-only query methods on the Directory Canister that allow users to discover their canister and look up other users.
What should be done:
- Implement
whoami()query: return the caller’sUserRecord(handle + canister ID) or an error if not registered - Implement
user_canister(opt principal)query: return the User Canister ID for the given principal (or the caller ifNone) - Implement
get_user(GetUserArgs)query: look up a user by handle, return their public info (handle, canister ID)
Acceptance Criteria:
whoamireturns the correct record for a registered userwhoamireturns an error for an unregistered calleruser_canister(None)returns the caller’s canisteruser_canister(Some(p))returns the canister for principalpget_userreturns the correct user for a valid handleget_userreturns an error for a non-existent handle
WI-0.6: Implement User Canister - profile and state management
Description: Implement the User Canister’s internal state, initialization, Ed25519 signing via IC threshold Schnorr, and profile query method.
What should be done:
- Use the database schema from WI-0.3 (
settings,profile,statuses,inbox,followers,followingtables) - Implement
initto acceptUserInstallArgs, create the schema, and persist init args into thesettingstable - Implement Ed25519 key retrieval and signing via the IC management
canister’s threshold Schnorr API (
schnorr_public_key,sign_with_schnorrwithSchnorrAlgorithm::Ed25519):- The public key is fetched once and cached in a thread-local
- Signing is performed on demand for HTTP Signatures
- An adapter trait (
SchnorrCanister) abstracts the management canister calls for testability
- Implement
get_profile()query: return the user’s profile (handle, display name, bio, avatar, created at) - Implement authorization guard: reject calls from non-owner principals for owner-only methods
Acceptance Criteria:
- The User Canister initializes correctly with the provided args
- The Ed25519 public key is retrievable via the Schnorr adapter
- Signing produces a valid Ed25519 signature via the Schnorr adapter
get_profilereturns the profile for any caller (public data)- Owner-only methods reject unauthorized callers
- State survives canister upgrades
WI-0.7: Implement User Canister - publish status
Description: Implement the publish_status method on the User Canister,
which stores a status in the user’s outbox (statuses table) and sends Create
activities to followers via the Federation Canister. Also expose send_activity
on the Federation Canister as a no-op stub (actual routing logic comes in
WI-0.10).
What should be done:
- Federation Canister: expose
send_activity(SendActivityArgs) -> SendActivityResponseas a no-op — accept the call, return success, do nothing (actual routing is WI-0.10) - User Canister: implement
publish_status(PublishStatusArgs):- Authorize the caller (owner only)
- Generate a Snowflake ID for the status
- Create and insert a
Statusrecord (id, content, visibility, created_at) into thestatusestable - Query the
followerstable - For each follower, build a
Create(Note)activity and callsend_activityon the Federation Canister - Return
PublishStatusResponsewith the new status ID
Acceptance Criteria:
- Only the owner can publish a status
- The status is stored in the
statusestable with a unique Snowflake ID - A
Create(Note)activity is sent for each follower viasend_activity - The status ID is returned to the caller
- Statuses persist across upgrades
send_activityis exposed on the Federation Canister (no-op for now)
WI-0.8: Implement User Canister - follow user
Description: Implement follow management methods (follow_user,
accept_follow, reject_follow), follower/following queries
(get_followers, get_following), and the receive_activity inbox handler.
What should be done:
- Implement
follow_user(FollowUserArgs):- Authorize the caller (owner only)
- Build a
Followactivity targeting the given handle/actor URI - Send the activity to the Federation Canister via
send_activity - Store a pending follow request locally
- Implement
accept_follow(AcceptFollowArgs):- Called by the Federation Canister when the target accepts
- Add the requester to the followers list
- Send an
Accept(Follow)activity back via the Federation Canister
- Implement
reject_follow(RejectFollowArgs):- Called by the Federation Canister when the target rejects
- Remove the pending follow request
- Send a
Reject(Follow)activity back via the Federation Canister
- Implement
get_followers(GetFollowersArgs)query:- Allow any caller (public query)
- Return paginated list of followers from the followers table
- Support cursor-based or offset-based pagination
- Implement
get_following(GetFollowingArgs)query:- Allow any caller (public query)
- Return paginated list of followed actors from the following table
- Support cursor-based or offset-based pagination
- Implement
receive_activity(ReceiveActivityArgs):- Authorize the caller (federation canister only)
- Handle incoming
Followactivities: auto-accept (for M0) and add to followers - Handle incoming
Accept(Follow): add to following list - Handle incoming
Reject(Follow): remove pending follow request from following list - Handle incoming
Create(Note): store in inbox
Acceptance Criteria:
follow_usersends a Follow activity and records a pending request- When an Accept is received, the target is added to the following list
- When a Reject is received, the pending request is removed from the following list
- When a Follow is received, the requester is added to the followers list
get_followersreturns the correct paginated follower listget_followingreturns the correct paginated following list- Only the Federation Canister can call
receive_activity - Only the owner can call
follow_user
WI-0.9: Implement User Canister - read feed
Description: Implement the read_feed method, which aggregates the user’s
inbox and outbox into a chronological, paginated feed.
What should be done:
- Implement
read_feed(ReadFeedArgs):- Authorize the caller (owner only)
- Merge inbox items (statuses from followed users) and outbox items (own statuses)
- Sort by timestamp descending
- Apply pagination (cursor-based or offset-based as defined in
ReadFeedArgs) - Return
ReadFeedResponsewith the page ofFeedItemrecords
- Visibility filtering in
read_feed:PublicandUnlistedinbox items: always shownFollowersOnlyinbox items: shown (the user is a follower by definition, since the item is in their inbox)Directinbox items: only shown if the owner is in theto/cclist (mentioned)
Acceptance Criteria:
- Feed contains both inbox and outbox items
- Items are sorted by timestamp (newest first)
- Pagination works correctly (returns the requested page size, provides a cursor/offset for the next page)
- An empty feed returns an empty list (no error)
- Only the owner can read their own feed
- Direct messages only appear for mentioned recipients
WI-0.9-2: Implement User Canister - get statuses (public outbox)
Description: Implement a public query to retrieve a user’s published
statuses, with visibility filtering. This is the read-side complement to
publish_status and is needed for profile views and federation outbox
collections.
What should be done:
- Implement
get_statuses(GetStatusesArgs)query:- Allow any caller (public query)
- Return paginated statuses from the
statusestable, sorted by timestamp descending - Apply visibility filtering based on the caller’s relationship to the
owner:
- Owner: see all statuses (including
DirectandFollowersOnly) - Follower: see
Public,Unlisted, andFollowersOnlystatuses - Anyone else: see only
PublicandUnlistedstatuses Directstatuses are never returned via this endpoint (they are only visible inread_feedfor mentioned users)
- Owner: see all statuses (including
- Support cursor-based or offset-based pagination
- Define
GetStatusesArgsandGetStatusesResponsein thedidcrate
Acceptance Criteria:
- Any caller can query a user’s public statuses
- Followers see
FollowersOnlystatuses; non-followers do not Directstatuses are excluded for all callers except the owner- Pagination works correctly
- Results are sorted by timestamp descending
WI-0.10: Implement Federation Canister - activity routing
Description: Implement the Federation Canister’s send_activity method,
which routes activities between local User Canisters via the Directory
Canister. Remote HTTP delivery is out of scope for Milestone 0.
What should be done:
- Define canister state using
ic-stable-structures: directory canister principal, domain name, set of authorized User Canister principals (the Federation Canister does not usewasm-dbms) - Implement
initto acceptFederationInstallArgsand persist state in stable memory - Implement a method to register User Canister principals (called by the Directory Canister during sign-up)
- Implement
send_activity(SendActivityArgs):- Authorize the caller (must be a registered User Canister)
- Parse the activity to determine the target actor(s)
- Apply visibility-aware delivery rules:
Public/Unlisted: deliver to all specified targetsFollowersOnly: deliver only to the sender’s followers (ignore non-follower targets)Direct: deliver only to explicitly mentioned actors (from the activity’sto/ccfields)
- For local targets: resolve the target User Canister via the Directory
Canister, then call
receive_activityon it - For remote targets: log/skip (federation is Milestone 2)
- Return
SendActivityResponse
Acceptance Criteria:
- Only registered User Canisters can call
send_activity - Local activities are correctly routed to the target User Canister
- The Federation Canister resolves local handles via the Directory Canister
- Remote targets are gracefully skipped (no crash)
FollowersOnlyactivities are only delivered to followersDirectactivities are only delivered to mentioned actors- Integration test: Alice follows Bob (both local), Bob sees Alice in followers
WI-0.11: Integration tests for Milestone 0 flows
Description: Write end-to-end integration tests using pocket-ic that exercise the complete Milestone 0 user flows.
What should be done:
- Test UC1 (Create Profile): Deploy Directory + Federation canisters,
call
sign_up, verify the User Canister is created and callable - Test UC2 (Sign In): After sign-up, call
whoamiand verify the correct canister ID is returned - Test UC7 (View Profile): After sign-up, call
get_useron the Directory, thenget_profileon the User Canister - Test UC5 (Follow User): Two users sign up, Alice follows Bob, verify follower/following lists
- Test UC9 (Create Status): Publish a status, verify it appears in the author’s outbox and is delivered to followers’ inboxes
- Test UC12 (Read Feed): Publish multiple statuses from different users, verify the feed is correctly aggregated and paginated
- Test Get Statuses: Alice publishes public and followers-only statuses,
Bob (follower) calls
get_statusesand sees both, Charlie (non-follower) callsget_statusesand sees only public statuses
Acceptance Criteria:
- All user story flows pass as integration tests
- Tests run in CI via
just integration_test - Tests use pocket-ic with realistic canister deployment
- Each test is independent and can run in isolation
Milestone 1 - Standalone Mastic Node
Duration: 3 months
Goal: Build all remaining local-node features: profile management (update, delete), status interactions (like, boost, delete), user search, and moderation. After this milestone, Mastic is a fully usable social network within a single node, ready for Fediverse integration.
User Stories: UC3, UC4, UC6, UC8, UC10, UC11, UC15, UC16, UC17, UC18, UC19, UC20, UC21
Prerequisites: Milestone 0 completed.
Work Items
WI-1.1: Extend database schema for Milestone 1
Description: Extend the wasm-dbms schema to support all new entities
introduced in Milestone 1: likes, boosts, blocks, tombstones, and status
deletion tracking.
What should be done:
- User Canister schema additions:
likedtable:status_uri(TEXT PK),created_at(INTEGER NOT NULL)blockstable:actor_uri(TEXT PK),created_at(INTEGER NOT NULL)- Add
like_count(INTEGER DEFAULT 0) andboost_count(INTEGER DEFAULT 0) columns to thestatusestable - Add
is_boost(INTEGER DEFAULT 0) andoriginal_status_uri(TEXT) columns to theinboxtable for boost tracking mediatable:id(TEXT PK),status_id(TEXT NOT NULL, FK → statuses.id),media_type(TEXT NOT NULL),description(TEXT),blurhash(TEXT),data(BLOB NOT NULL),created_at(INTEGER NOT NULL)- Add index on
media.status_id
- Directory Canister schema additions:
tombstonestable:handle(TEXT PK),deleted_at(INTEGER NOT NULL),expires_at(INTEGER NOT NULL)- Add index on
tombstones.expires_atfor cleanup
- Run schema migrations on canister upgrade (add new tables/columns without losing existing data)
Acceptance Criteria:
- New tables and columns are created on upgrade from Milestone 0 schema
- Existing data is preserved during migration
- Unit tests verify migration from M0 schema to M1 schema
- All new queries needed by M1 work items are supported
WI-1.2: Implement User Canister - update profile (UC3)
Description: Allow the user to update their profile fields and propagate the change to followers via an Update activity.
What should be done:
- Implement
update_profile(UpdateProfileArgs):- Authorize the caller (owner only)
- Accept optional fields: display name, bio, avatar URL, header URL
- Update the profile in stable memory (only fields that are
Some) - Build an
Update(Person)activity - Send the activity to the Federation Canister via
send_activityso followers (local for now) receive the updated profile
- Define
UpdateProfileArgsandUpdateProfileResponsein thedidcrate if not already present
Acceptance Criteria:
- Only the owner can update their profile
- Partial updates work (e.g., updating only the bio leaves other fields unchanged)
- The updated profile is returned by
get_profile - An
Updateactivity is sent to the Federation Canister - Integration test: update profile, verify
get_profilereturns new values
WI-1.3: Implement delete profile flow (UC4)
Description: Implement account deletion across Directory, User, and Federation canisters.
What should be done:
- Directory Canister: Implement
delete_profile():- Authorize the caller (must be a registered user)
- Create a tombstone record for the user (prevents handle reuse for a grace period)
- Notify the User Canister to aggregate Delete activities
- After activities are sent, delete the User Canister via the IC
management canister (
stop_canister+delete_canister) - Remove the user record from the directory
- User Canister: Implement
delete_profile():- Authorize the caller (owner only)
- Aggregate a
Delete(Person)activity for all followers - Send activities to the Federation Canister
- Return success
- Federation Canister: Handle
Delete(Person)activities:- Buffer the activity data before forwarding (the User Canister will be destroyed)
- Route to local followers via the Directory Canister
- For remote: skip (Milestone 2)
- Define
DeleteProfileResponsein thedidcrate
Acceptance Criteria:
- Calling
delete_profileon the Directory removes the user record - The User Canister is stopped and deleted via the IC management canister
- A
Deleteactivity is delivered to local followers - The deleted user’s handle cannot be reused immediately (tombstone)
whoamireturns an error after deletionget_userreturns an error for the deleted handle- Integration test: create user, delete, verify canister is gone
WI-1.4: Implement User Canister - unfollow user (UC6)
Description: Allow a user to unfollow another user and notify the target via an Undo(Follow) activity.
What should be done:
- Implement
unfollow_user(UnfollowUserArgs):- Authorize the caller (owner only)
- Remove the target from the following list
- Build an
Undo(Follow)activity - Send the activity to the Federation Canister
- User Canister
receive_activityhandler: handle incomingUndo(Follow):- Remove the requester from the followers list
- Define
UnfollowUserArgs,UnfollowUserResponsein thedidcrate - Add
UndotoActivityTypein thedidcrate
Acceptance Criteria:
- After unfollowing, the target is removed from the following list
- The target’s follower list no longer contains the caller
- An
Undo(Follow)activity is delivered to the target - Unfollowing a user you don’t follow returns a descriptive error
- Integration test: follow, then unfollow, verify lists are updated
WI-1.5: Implement Directory Canister - search profiles (UC8)
Description: Implement the search_profiles method for user discovery.
What should be done:
- Implement
search_profiles(SearchProfilesArgs)query:- Accept a search query string and pagination parameters
- Search by handle prefix or substring match
- Return a paginated list of matching users (handle + canister ID)
- Define
SearchProfilesArgs,SearchProfilesResponsein thedidcrate
Acceptance Criteria:
- Searching by exact handle returns the correct user
- Searching by prefix returns all matching users
- Empty query returns a paginated list of all users
- Pagination works correctly
- Results do not include suspended or deleted users
- Integration test: create multiple users, search, verify results
WI-1.6: Implement User Canister - like status (UC10)
Description: Allow a user to like a status and notify the author.
What should be done:
- Implement
like_status(LikeStatusArgs):- Authorize the caller (owner only)
- Record the like in the user’s liked collection (stable memory)
- Build a
Likeactivity targeting the status - Send the activity to the Federation Canister
- Idempotent: if the caller has already liked the status, return
Okwithout inserting a duplicate row and without re-sending theLikeactivity
- Implement
get_liked(GetLikedArgs)query:- Return the paginated list of statuses liked by the user
- Implement
undo_like(UnlikeStatusArgs):- Remove the like from the liked collection
- Send an
Undo(Like)activity to the Federation Canister
- User Canister
receive_activityhandler: handle incomingLike:- Increment the like count on the target status
- Handle incoming
Undo(Like):- Decrement the like count on the target status
- Define
LikeStatusArgs,LikeStatusResponse,UnlikeStatusArgs,UnlikeStatusResponse,GetLikedArgs,GetLikedResponsein thedidcrate - Add
LiketoActivityType
Acceptance Criteria:
- Liking a status records it in the liked collection
- A
Likeactivity is sent to the status author - The author’s status like count is incremented
get_likedreturns the correct list- Undoing a like removes it and sends an
Undo(Like)activity - Liking a status the caller already liked is idempotent: returns
Ok, does not insert a duplicate, and does not re-send theLikeactivity - Integration test: Alice likes Bob’s status, verify like count and liked list
- Integration test: Alice calls
like_statustwice on the same status, verify second call returnsOk, liked list contains a single entry, and Bob’s status like count is incremented exactly once
WI-1.7: Implement User Canister - boost status (UC11)
Description: Allow a user to boost (reblog) a status and notify both the author and the user’s followers.
What should be done:
- Implement
boost_status(BoostStatusArgs { status_url }):- Authorize the caller (owner only) at the inspect layer
- Idempotent: if a boost row for
status_urlalready exists, returnOkwithout inserting a duplicate row and without re-dispatching the activity - Otherwise:
- Resolve the original status (content, author URI, visibility,
spoiler, sensitive flag) by looking up the local
statusestable when the target is local, or the local inbox row when the target is remote - Insert a wrapper row into
statusesowned by the booster with a denormalized copy of the original content fields. This is the booster’s outbox entry for the boost - Insert a row into
boostslinking the wrapper (status_id) to theoriginal_status_uri - Insert a
feedentry withsource = Outboxfor the wrapper so the boost appears in the booster’s own feed - Build an
Announceactivity and send it to the Federation Canister (targets: status author + all of the booster’s followers)
- Resolve the original status (content, author URI, visibility,
spoiler, sensitive flag) by looking up the local
- Implement
undo_boost(UndoBoostArgs { status_url }):- Authorize the caller (owner only) at the inspect layer
- Idempotent: if no boost row for
status_urlexists, returnOkwithout dispatching anUndo(Announce)activity - Otherwise, remove the
boostsrow, the wrapperstatusesrow, and the correspondingfeedoutbox entry. Then send anUndo(Announce)activity to the same targets
- User Canister
receive_activityhandler: handle incomingAnnounce:- Insert an
inboxrow withis_boost = true,original_status_uri = <target>,actor_uri = booster,activity_type = Announce - Insert a
feedentry withsource = Inboxfor that row so the boost appears in the recipient’s feed - If the local target status exists, increment
statuses.boost_count(saturating)
- Insert an
- Handle incoming
Undo(Announce):- Delete the matching inbox row and its
feedentry - If the local target status exists, decrement
statuses.boost_count(saturating at 0)
- Delete the matching inbox row and its
- Feed rendering (
read_feed):- Outbox path: when a
statusesrow is referenced by aboostsrow, hydrate the feed item using the denormalized copy and setboosted_by = Some(owner_actor_uri),author = original author URI. Closes the existingboosted_by: NoneFIXME incrates/canisters/user/src/domain/feed/read_feed.rs - Inbox path: when an
inboxrow hasis_boost = true, hydrate the feed item by resolvingoriginal_status_uri(localstatusesif present, otherwise from any cached inbox row), and setboosted_by = Some(actor_uri),author = original author URI. Closes the corresponding inbox-side FIXME
- Outbox path: when a
- Define
BoostStatusArgs,BoostStatusResponse,UndoBoostArgs,UndoBoostResponsein thedidcrate. Both error enums expose onlyInternal(String); authorization failures are rejected at the inspect layer (noUnauthorizedvariant) - Extend
Status/FeedItemwithboosted_by: Option<Text> - Add
AnnouncetoActivityType
Edit propagation note: the wrapper row holds a denormalized copy of
the original status content. When the original author edits the status
(WI-1.23), the resulting Update(Note) activity received by the
booster’s canister must overwrite the wrapper’s content,
spoiler_text, and sensitive fields in addition to the inbox row’s
object_data. WI-1.23 covers this propagation.
Acceptance Criteria:
- Boosting a status inserts a wrapper row into the booster’s outbox with a denormalized copy of the original content
- A
boostsrow links the wrapper tooriginal_status_uri - The booster sees their own boost in their feed with
boosted_by = selfandauthor = original author URI - An
Announceactivity is sent to the author and the booster’s followers - Followers see the boost in their feed with
boosted_by = boosterandauthor = original author URI boost_statusis idempotent: calling it twice for the samestatus_urlreturnsOkboth times, stores a single boost row + wrapper, and dispatches theAnnounceactivity only once- Undoing a boost removes the
boostsrow, the wrapper status, the outbox feed entry, and dispatches anUndo(Announce)activity - Receivers delete the inbox boost row and feed entry on
Undo(Announce) undo_boostis idempotent: calling it for a status that is not boosted returnsOkwithout dispatching a secondUndo(Announce)- Self-boost is allowed
- Integration test: Alice boosts Bob’s status, Charlie (Alice’s follower)
sees it in his feed with
boosted_by = aliceandauthor = bob; Alice also sees the boost in her own feed; undo removes it from both
WI-1.8: Implement User Canister - delete status (UC15)
Description: Allow both the status owner and moderators to delete a status.
What should be done:
- Implement
delete_status(DeleteStatusArgs):- Authorize the caller: must be the owner or a moderator (the moderator list is resolved from the Directory Canister)
- Remove the status from the outbox
- Build a
Delete(Note)activity - Send the activity to the Federation Canister to notify followers
- Define
DeleteStatusArgs,DeleteStatusResponsein thedidcrate - Add
DeletetoActivityTypeif not already present
Acceptance Criteria:
- The owner can delete their own status
- A moderator can delete any user’s status
- Non-owner, non-moderator callers are rejected
- A
Delete(Note)activity is sent to followers - The status no longer appears in feeds after deletion
- Integration test: publish status, delete it, verify it’s gone from feeds
WI-1.9: Implement Directory Canister - moderation (UC16)
Description: Implement moderator management and user suspension on the Directory Canister.
What should be done:
- Implement
add_moderator(AddModeratorArgs):- Authorize the caller (must be an existing moderator)
- Add the target principal to the moderator list
- Implement
remove_moderator(RemoveModeratorArgs):- Authorize the caller (must be an existing moderator)
- Prevent removing the last moderator
- Remove the target principal from the moderator list
- Implement
suspend(SuspendArgs):- Authorize the caller (must be a moderator)
- Mark the user as suspended in the directory
- Notify the User Canister to send a
Deleteactivity to followers - Suspended users cannot call any methods on their User Canister
- Define
AddModeratorArgs,AddModeratorResponse,RemoveModeratorArgs,RemoveModeratorResponse,SuspendArgs,SuspendResponsein thedidcrate
Acceptance Criteria:
- Only moderators can add/remove moderators
- The last moderator cannot be removed
- Suspending a user marks them as inactive in the directory
- Suspended users cannot interact with their User Canister
- A
Deleteactivity is sent to the suspended user’s followers search_profilesexcludes suspended users- Integration test: add moderator, suspend user, verify user is locked out
WI-1.10: Implement User Canister - block user
Description: Allow a user to block another user, preventing interactions.
What should be done:
- Implement
block_user(BlockUserArgs):- Authorize the caller (owner only)
- Record the block locally (block list in stable memory)
- If the blocked user is a follower, remove them from the followers list
- If the owner follows the blocked user, remove from following list
- Send a
Blockactivity to the Federation Canister
- User Canister
receive_activityhandler: handle incomingBlock:- Hide the blocking user’s content from the blocked user
- Activities from blocked users should be silently dropped in
receive_activity - Define
BlockUserArgs,BlockUserResponsein thedidcrate - Add
BlocktoActivityType
Acceptance Criteria:
- Blocking a user removes mutual follow relationships
- Activities from a blocked user are dropped
- A
Blockactivity is sent via the Federation Canister - The blocked user does not appear in the blocker’s feeds
- Integration test: Alice blocks Bob, verify follow removed and activities dropped
WI-1.12: Implement Directory Canister - upgrade user canisters (UC17)
Description: Implement a controller-only method to batch-upgrade all User Canister WASMs via a timer-based state machine.
What should be done:
- Implement
upgrade_user_canisters(UpgradeUserCanistersArgs):- Authorize the caller (must be a controller via
ic_cdk::api::is_controller) - Reject if an upgrade is already in progress
- Store the provided WASM blob
- Build an upgrade queue containing all registered user canister IDs
- Start a recurring timer that processes canisters in batches (5-10 per tick)
- Authorize the caller (must be a controller via
- Implement the upgrade state machine (mirrors sign-up flow pattern):
- Per-canister states:
Pending,Upgrading,Completed,Failed(attempts),PermanentlyFailed - On each tick: pick next batch of
PendingorFailed(n < 5)canisters, callinstall_code(mode: upgrade) via the management canister - On success: mark
Completed - On failure: increment attempt counter; if attempts >= 5, mark
PermanentlyFailed - When all canisters are processed, stop the timer and mark the batch as completed
- Per-canister states:
- Implement
get_upgrade_status()query:- Return
UpgradeStatuswith: total count, completed count, failed count, permanently failed count, and whether an upgrade is in progress
- Return
- Define
UpgradeUserCanistersArgs,UpgradeUserCanistersResponse,UpgradeStatusin thedidcrate
Acceptance Criteria:
- Only the controller can call
upgrade_user_canisters - Concurrent upgrade requests are rejected
- Canisters are upgraded in batches without hitting instruction limits
- Failed canisters are retried up to 5 times
- After 5 failures a canister is marked as permanently failed and skipped
get_upgrade_statusaccurately reports progress- Integration test: deploy user canisters, trigger upgrade with new WASM, verify all canisters run the new version
WI-1.13: Implement Directory Canister - sign-up fee (UC18)
Description: Require callers to attach cycles when signing up to cover the cost of User Canister creation, preventing spam account creation.
What should be done:
- Modify the existing
sign_upflow in the Directory Canister:- Before any processing, check
ic_cdk::api::call::msg_cycles_available()against the required fee (canister creation fee + initial cycles, both existing constants) - If insufficient: reject with
InsufficientCycles { required, provided }error - If sufficient: accept cycles via
msg_cycles_accept()and proceed with the existing sign-up flow, forwarding cycles to the management canister for canister creation
- Before any processing, check
- Add
InsufficientCyclesvariant to the sign-up error type in thedidcrate - Update existing sign-up integration tests to attach the required cycles
Acceptance Criteria:
- Sign-up without cycles is rejected with a clear error showing required amount
- Sign-up with insufficient cycles is rejected
- Sign-up with exact or excess cycles succeeds
- Accepted cycles are forwarded to the management canister for User Canister creation
- Existing sign-up tests are updated and pass
- Integration test: attempt sign-up without cycles, verify rejection; sign up with cycles, verify success
WI-1.14: Implement User Canister - action rate limiting (UC19)
Description: Enforce a per-user rate limit on mutating social actions to prevent action spam.
What should be done:
- Implement a rate limiter module in the User Canister:
- Circular buffer of 20 timestamps stored in heap memory
- On each rate-limited call: check if the oldest entry is less than 60 seconds ago
- If yes: reject with
RateLimitExceedederror - If no: record the current timestamp and proceed
- Apply the rate limiter at the top of these methods:
post_status,delete_statusfollow_user,unfollow_userlike_status,undo_likeboost_status,undo_boostblock_user
- Add
RateLimitExceededvariant to the relevant error types in thedidcrate - Constants: 20 actions per 60-second window (compile-time)
Acceptance Criteria:
- Actions within the limit succeed normally
- The 21st action within 60 seconds is rejected with
RateLimitExceeded - After 60 seconds the window slides and actions succeed again
- Rate limit state resets on canister upgrade (heap-only)
- All rate-limited methods enforce the check
- Unit test: simulate rapid actions, verify rejection at threshold
- Integration test: call 20 actions in quick succession, verify 21st fails
WI-1.15: Implement User Canister - reply to status (UC20)
Description: Allow a user to reply to an existing status, creating a
thread. This is essential for meaningful social interaction and for Fediverse
compatibility (M3), where remote instances expect inReplyTo on Note
objects.
What should be done:
- Extend
PublishStatusArgsin thedidcrate:- Add
in_reply_tofield (optional status URI string)
- Add
- Extend the
Statustype in thedidcrate:- Add
in_reply_tofield (optional status URI string)
- Add
- Extend the
statusesdatabase table:- Add
in_reply_to_uricolumn (TEXT, nullable) - Add index on
in_reply_to_urifor thread lookups
- Add
- Update
publish_statusin the User Canister:- If
in_reply_tois provided, validate the URI format - Store the
in_reply_to_uriin the statuses table - Include
inReplyToin theCreate(Note)activity sent via the Federation Canister - Send the activity to both the original author and the caller’s followers
- If
- Implement
get_thread(GetThreadArgs)query:- Accept a status ID
- Return the status and all replies (statuses where
in_reply_to_urimatches), sorted chronologically - Support pagination
- Update
receive_activityhandler forCreate(Note):- Parse and store the
inReplyTofield from incoming notes
- Parse and store the
- Define
GetThreadArgs,GetThreadResponsein thedidcrate
Acceptance Criteria:
- A reply status is created with a valid
in_reply_toreference - The reply appears in the author’s outbox and followers’ inboxes
- The original status author receives the reply in their inbox
get_threadreturns the original status and all its replies in order- Replying to a non-existent status URI still succeeds (the URI is stored as-is for federation compatibility)
- The
Create(Note)activity includes theinReplyTofield - Incoming
Create(Note)activities withinReplyToare stored correctly
WI-1.16: Implement User Canister - media attachments (UC21)
Description: Allow users to attach media files (images, video, audio) to
statuses. Media is stored as blobs in the User Canister’s media table,
linked to a status via foreign key.
What should be done:
- Define types in the
didcrate:MediaAttachment: id, media_type, description (alt text), blurhash- Add
mediafield (Vec of media bytes + metadata) toPublishStatusArgs - Add
mediafield (Vec ofMediaAttachment) toStatus
- Update
publish_statusin the User Canister:- For each attachment: generate an ID, store the blob and metadata in the
mediatable with the status ID as foreign key - Include attachment metadata in the
Create(Note)activity (as ActivityPubAttachmentobjects with media type, name/description, and a URL pointing to the media retrieval endpoint)
- For each attachment: generate an ID, store the blob and metadata in the
- Implement
get_media(GetMediaArgs)query:- Accept a media ID
- Return the raw media blob and its content type
- Allow any caller (public query, needed for federation)
- Implement
get_status_media(GetStatusMediaArgs)query:- Accept a status ID
- Return the list of
MediaAttachmentmetadata for that status
- Update
receive_activityhandler forCreate(Note):- Parse attachment metadata from incoming notes and store references (remote media is referenced by URL, not downloaded)
- Define
GetMediaArgs,GetMediaResponse,GetStatusMediaArgs,GetStatusMediaResponsein thedidcrate - When a status is deleted (
delete_status), cascade-delete its media rows
Acceptance Criteria:
- A status can be published with one or more media attachments
- Media blobs are stored in the
mediatable linked to the status get_mediareturns the correct blob and content typeget_status_mediareturns metadata for all attachments on a status- The
Create(Note)activity includes attachment objects - Deleting a status also deletes its associated media
- Incoming notes with attachments store the remote attachment metadata
- A status without attachments works as before (no regression)
WI-1.11: Integration tests for Milestone 1 flows
Description: Write end-to-end integration tests for all Milestone 1 user stories.
What should be done:
- Test UC3 (Update Profile): Update profile fields, verify changes
- Test UC4 (Delete Profile): Delete account, verify canister removed
- Test UC6 (Unfollow): Follow then unfollow, verify lists
- Test UC8 (Search): Create users, search by prefix, verify results
- Test UC10 (Like): Like a status, verify like count and liked list; undo like
- Test UC11 (Boost): Boost a status, verify followers see it; undo boost
- Test UC15 (Delete Status): Publish and delete status, verify removal
- Test UC16 (Moderation): Add moderator, suspend user, verify lockout
- Test Block: Block user, verify follow removal and activity filtering
- Test UC17 (Upgrade): Deploy user canisters, trigger WASM upgrade, verify all run new version
- Test UC18 (Sign-up Fee): Attempt sign-up without cycles, verify rejection; sign up with cycles, verify success
- Test UC19 (Rate Limit): Perform 20 rapid actions, verify 21st is rejected
- Test UC20 (Reply): Alice publishes a status, Bob replies, verify
reply has
in_reply_toset, verifyget_threadreturns both statuses in order, verify Alice receives the reply in her inbox - Test UC21 (Media): Alice publishes a status with a media attachment,
verify
get_status_mediareturns the attachment metadata, verifyget_mediareturns the blob, delete the status and verify the media is also deleted
Acceptance Criteria:
- All user story flows pass as integration tests
- Tests run in CI via
just integration_test - Each test is independent and can run in isolation
- Tests cover both success and error paths
Milestone 2 - Frontend
Duration: 2 months
Goal: Build the Mastic web frontend as a React application deployed as an IC asset canister. The frontend provides Internet Identity authentication and a Mastodon-like interface covering all user stories from Milestones 0 and 1. After this milestone, users can interact with the Mastic node entirely through a browser.
User Stories: UC1, UC2, UC3, UC4, UC5, UC6, UC7, UC8, UC9, UC10, UC11, UC12, UC15, UC16, UC20, UC21
Prerequisites: Milestone 1 completed.
Work Items
WI-2.1: Frontend project scaffold & Internet Identity authentication
Description: Set up the React project as an IC asset canister with Internet Identity sign-in, agent configuration, and basic app shell.
What should be done:
- Initialize a React project (Vite + TypeScript) under
crates/canisters/frontend/ - Configure
dfx.jsonwith the frontend asset canister - Set up
@dfinity/agentwith actor factories generated from the.didfiles for Directory, Federation, and User canisters - Integrate
@dfinity/auth-clientfor Internet Identity sign-in/sign-out - Implement sign-up page: handle input + call
sign_upon the Directory Canister - Post-auth routing: call
whoamito resolve the User Canister principal, store in app state - Basic app shell: navigation bar with auth status, client-side routing skeleton
Acceptance Criteria:
- The frontend deploys as an asset canister via
dfx deploy - Users can sign in with Internet Identity and sign out
- New users can sign up by choosing a handle
- After sign-in, the app resolves the User Canister principal and stores it in state
- The navigation bar shows the authenticated user’s handle
- Routing works for at least
/,/sign-up, and a placeholder home page
WI-2.2: Feed view & status composer
Description: Build the main timeline view with paginated feed and a status composer for publishing new statuses.
What should be done:
- Implement a feed page that calls
read_feedon the User Canister and renders a paginated list of statuses - Build a status card component displaying: author handle, display name, avatar, content, timestamp, like count, boost count
- Implement infinite scroll or “load more” pagination using the cursor
returned by
read_feed - Build a compose form: text input with character count + call
publish_statuson the User Canister - New statuses appear at the top of the feed after publishing
Acceptance Criteria:
- The feed displays statuses from followed users and the user’s own statuses
- Pagination loads additional statuses without reloading the page
- Status cards show all required fields (author, content, timestamp, counts)
- Publishing a status adds it to the feed
- Empty feed shows a meaningful placeholder message
WI-2.3: Profile view & management
Description: Display user profiles and allow users to edit or delete their own profile.
What should be done:
- Implement a profile page at
/users/{handle}:- Call
get_useron the Directory Canister to resolve the User Canister - Call
get_profileon the target User Canister - Display: handle, display name, bio, avatar, header image, follower/following counts
- Call
- Own profile: show an edit form for display name, bio, avatar URL, and
header URL that calls
update_profileon the User Canister - Delete account flow: confirmation dialog that calls
delete_profileon the Directory Canister, then redirects to the landing page - Make author names/avatars in status cards clickable to navigate to the author’s profile
Acceptance Criteria:
- Any user’s profile can be viewed by navigating to
/users/{handle} - The own profile displays an edit button that opens the edit form
- Updating a field persists the change (verified by reloading the profile)
- Account deletion requires confirmation and redirects after success
- Author links in the feed navigate to the correct profile page
WI-2.4: Follow, like & boost interactions
Description: Implement follow/unfollow, like/unlike, and boost/unboost UI interactions.
What should be done:
- Follow/unfollow button on profile pages:
- Show “Follow” or “Unfollow” based on current relationship
- Call
follow_user/unfollow_useron the User Canister
- Followers and following lists on the profile page:
- Call
get_followers/get_followingon the User Canister - Render paginated lists of user cards linking to their profiles
- Call
- Like button on status cards:
- Toggle like state, call
like_status/undo_like - Update like count optimistically
- Toggle like state, call
- Boost button on status cards:
- Toggle boost state, call
boost_status/undo_boost - Update boost count optimistically
- Toggle boost state, call
- Liked statuses page: call
get_likedand render the list
Acceptance Criteria:
- Follow/unfollow toggles correctly and updates the button state
- Followers and following lists display correct users with pagination
- Like and boost buttons toggle state and update counts immediately
- Undoing a like or boost reverses the action
- The liked statuses page shows all statuses the user has liked
WI-2.5: User search
Description: Implement a search interface for discovering users on the Mastic node.
What should be done:
- Add a search bar in the navigation or a dedicated search page
- Call
search_profileson the Directory Canister with the query string - Display results as user cards (avatar, handle, display name) linking to the user’s profile
- Implement pagination for search results
- Debounce input to avoid excessive queries
Acceptance Criteria:
- Typing a query returns matching users
- Results link to the correct user profiles
- Pagination works when there are many results
- Empty or whitespace-only queries are handled gracefully
- No excessive API calls while the user is still typing
WI-2.6: Moderation tools
Description: Build moderator-specific UI for content and user moderation. These controls are only visible to users who are moderators.
What should be done:
- Detect moderator status (e.g., by checking with the Directory Canister whether the current principal is a moderator)
- Show a delete button on any status card when the user is a moderator:
- Call
delete_statuson the author’s User Canister - Remove the status from the feed on success
- Call
- Show a suspend button on user profiles when the user is a moderator:
- Confirmation dialog explaining the action
- Call
suspendon the Directory Canister
- Moderator management page (accessible from settings or nav):
- List current moderators
- Add moderator by principal: call
add_moderator - Remove moderator: call
remove_moderator(with safeguard against removing the last moderator)
- Block user button on profile pages (available to all users):
- Call
block_useron the User Canister
- Call
Acceptance Criteria:
- Non-moderators do not see moderation controls (delete on others’ statuses, suspend, moderator management)
- Moderators can delete any status and it disappears from the feed
- Moderators can suspend a user, who then cannot interact with the platform
- The moderator list is displayed correctly
- Adding and removing moderators works, with a safeguard against removing the last one
- Any user can block another user from their profile page
WI-2.7: Frontend build pipeline & deployment
Description: Integrate the frontend build into the existing just
command workflow and CI pipeline.
What should be done:
- Add
just build_frontendcommand: runs the Vite production build and outputs to the asset canister directory - Add
just dfx_deploy_frontendcommand: deploys the asset canister locally - Update
just build_allto include the frontend build - Update
just dfx_deploy_localto include the frontend canister - Ensure the frontend build works in CI (install Node.js dependencies, run build)
- Add
just test_frontendcommand for running frontend unit tests
Acceptance Criteria:
just build_frontendproduces a production build without errorsjust dfx_deploy_localdeploys all canisters including the frontend- The deployed frontend is accessible at the asset canister URL
- CI can build and deploy the frontend
- Frontend unit tests run via
just test_frontend
WI-2.8: Frontend end-to-end tests
Description: Write end-to-end tests that exercise the full user journey through the frontend against real canisters deployed on a local dfx replica.
What should be done:
- Set up a Playwright (or Cypress) test harness:
- Install test dependencies and configure the test runner
- Add
just test_frontend_e2ecommand - Tests start a local dfx replica, deploy all canisters, and run against the deployed frontend
- Implement e2e test flows:
- Sign-up & sign-in: Navigate to sign-up page, create account with a handle, verify redirect to home feed
- Publish status: Compose and publish a text status, verify it appears in the feed
- View profile: Navigate to own profile, verify handle, display name, and status list are shown
- Update profile: Edit display name and bio, verify changes persist after reload
- Follow user: Navigate to another user’s profile, click follow, verify follower count updates and statuses appear in feed
- Unfollow user: Unfollow a followed user, verify follower count decreases
- Like & boost: Like and boost a status, verify count updates; undo both, verify counts revert
- Reply to status: Reply to a status, verify the reply appears in the thread view
- Media attachment: Publish a status with an image attachment, verify it renders in the feed
- Search: Search for a user by handle prefix, verify results link to the correct profile
- Delete status: Delete own status, verify it disappears from feed
- Delete account: Delete account, verify redirect to landing page and sign-in fails
- Moderation (moderator role): As a moderator, delete another user’s status and suspend a user, verify the actions take effect
- Block user: Block a user, verify their content is hidden
- CI integration: add e2e tests to the CI pipeline (may run as a separate job due to replica startup time)
Acceptance Criteria:
- All e2e test flows pass against a local dfx replica
- Tests run via
just test_frontend_e2e - Tests are independent and idempotent (each test sets up its own state)
- CI runs e2e tests (or they can be triggered manually)
- Tests cover both happy paths and key error cases (e.g., duplicate handle on sign-up)
Milestone 3 - Integrating the Fediverse
Duration: 2 months
Goal: Implement the Federation Protocol to make Mastic fully compatible with the Fediverse. Remote Mastodon instances can discover Mastic users via WebFinger, fetch actor profiles, and exchange activities over HTTP with ActivityPub and HTTP Signatures.
User Stories: UC13, UC14
Prerequisites: Milestone 2 completed.
Work Items
WI-3.1: Extend database schema for Milestone 3
Description: Extend the wasm-dbms schema to support federation-specific
data: remote actor cache, delivery queue, and HTTP signature key references.
What should be done:
- Federation Canister schema:
remote_actorstable:actor_uri(TEXT PK),inbox_url(TEXT NOT NULL),shared_inbox_url(TEXT),public_key_pem(TEXT NOT NULL),display_name(TEXT),summary(TEXT),icon_url(TEXT),fetched_at(INTEGER NOT NULL),expires_at(INTEGER NOT NULL)delivery_queuetable:id(TEXT PK),activity_json(TEXT NOT NULL),target_inbox_url(TEXT NOT NULL),sender_canister_id(TEXT NOT NULL),attempts(INTEGER DEFAULT 0),last_attempt_at(INTEGER),status(TEXT NOT NULL DEFAULT ‘pending’),created_at(INTEGER NOT NULL)authorized_canisterstable:canister_id(TEXT PK),registered_at(INTEGER NOT NULL)- Index on
delivery_queue.statusfor pending delivery lookup - Index on
remote_actors.expires_atfor cache eviction
- User Canister schema additions:
- Add
actor_uri(TEXT) column tofollowersandfollowingtables to distinguish local vs remote actors
- Add
- Run schema migrations on canister upgrade
Acceptance Criteria:
- New tables and columns are created on upgrade from M2 schema
- Existing data is preserved during migration
- Cache eviction queries work on the
remote_actorstable - Delivery queue supports retry queries (find pending with attempts < max)
WI-3.2: Implement WebFinger endpoint
Description: Serve WebFinger responses so remote instances can discover
Mastic users by their acct: URI.
What should be done:
- In the Federation Canister, handle
GET /.well-known/webfingerinhttp_request(query) - Parse the
resourcequery parameter (e.g.,acct:alice@mastic.social) - Extract the handle, resolve it via the Directory Canister
- Return a JSON Resource Descriptor (JRD) with:
subject: theacct:URIlinks: aselflink pointing to the actor’s ActivityPub profile URL withtype: application/activity+json
- Return 404 for unknown handles
- Return 400 for malformed requests
Acceptance Criteria:
GET /.well-known/webfinger?resource=acct:alice@mastic.socialreturns a valid JRD with the correct actor URL- Unknown handles return 404
- Malformed
resourceparameters return 400 - Response has
Content-Type: application/jrd+json - Integration test: create user, query WebFinger, verify JRD
WI-3.3: Serve ActivityPub actor profiles
Description: Serve actor profile JSON for remote instances that look up Mastic users.
What should be done:
- In the Federation Canister, handle
GET /users/{handle}inhttp_request(query) whenAcceptheader includesapplication/activity+json - Resolve the handle via the Directory Canister
- Fetch the user’s profile from their User Canister
- Fetch the user’s RSA public key from their User Canister
- Build an ActivityPub
Personobject with:id,url,preferredUsername,name,summaryinbox,outbox,followers,followingcollection URLspublicKeyblock (key ID, owner, PEM-encoded RSA public key)iconandimageif avatar/header are set
- Return the JSON-LD response
Acceptance Criteria:
GET /users/alicewith the correct Accept header returns a valid ActivityPub Person object- The
publicKeyblock contains the correct RSA public key - Collection URLs are well-formed
- Unknown handles return 404
- Integration test: create user, fetch actor profile, verify all fields
WI-3.4: Serve ActivityPub collections
Description: Serve the outbox, followers, and following
OrderedCollection endpoints for remote instances.
What should be done:
- Handle
GET /users/{handle}/outboxinhttp_request:- Return an
OrderedCollectionwithtotalItemsand paginatedOrderedCollectionPageitems - Fetch outbox items from the User Canister
- Return an
- Handle
GET /users/{handle}/followersinhttp_request:- Return an
OrderedCollectionof follower actor URIs
- Return an
- Handle
GET /users/{handle}/followinginhttp_request:- Return an
OrderedCollectionof following actor URIs
- Return an
- Support pagination via
pagequery parameter
Acceptance Criteria:
- Each collection endpoint returns valid ActivityPub OrderedCollection JSON
- Pagination works correctly
- Empty collections return
totalItems: 0 - Unknown handles return 404
- Integration test: create user with statuses and follows, verify collections
WI-3.5: Implement HTTP Signatures for outgoing requests
Description: Sign all outgoing HTTP requests from the Federation Canister using the sender’s RSA private key, per the HTTP Signatures spec used by Mastodon.
What should be done:
- Implement HTTP Signature generation:
- Sign headers:
(request-target),host,date,digest,content-type - Use RSA-SHA256 algorithm
- Fetch the sender’s private key from their User Canister
- Build the
Signatureheader string
- Sign headers:
- Add the
SignatureandDigestheaders to all outgoing ActivityPub requests - Implement a helper to compute SHA-256 digest of the request body
Acceptance Criteria:
- All outgoing ActivityPub requests include a valid
Signatureheader - The
Digestheader matches the SHA-256 hash of the body - The signature can be verified using the sender’s public key
- Unit test: sign a request, verify the signature with the public key
WI-3.6: Implement HTTP Signature verification for incoming requests
Description: Verify HTTP Signatures on incoming ActivityPub requests to ensure authenticity.
What should be done:
- In the Federation Canister
http_request_updatehandler, before processing any incoming activity:- Parse the
Signatureheader to extractkeyId,headers,signature - Fetch the remote actor’s profile from the
keyIdURL (viaic_cdk::api::management_canister::http_request) - Extract the remote actor’s RSA public key
- Reconstruct the signing string from the specified headers
- Verify the signature using the remote public key
- Parse the
- Cache remote actor public keys to avoid repeated fetches (with TTL)
- Reject requests with invalid or missing signatures
Acceptance Criteria:
- Incoming requests with valid signatures are accepted
- Incoming requests with invalid signatures are rejected with 401
- Incoming requests with missing signatures are rejected with 401
- Remote public keys are cached with a reasonable TTL
- Unit test: construct a signed request, verify it passes validation
WI-3.7: Implement incoming activity processing (inbox)
Description: Process incoming ActivityPub activities received via HTTP POST to the shared inbox.
What should be done:
- In the Federation Canister, handle
POST /inboxinhttp_request_update:- Verify HTTP Signature (WI-3.5)
- Parse the activity JSON
- Determine the activity type and target
- Route to the appropriate User Canister(s) via
receive_activity
- Handle the following incoming activity types:
Create(Note): deliver to the target user’s inboxFollow: deliver to the target user for acceptanceAccept(Follow): deliver to the original requesterReject(Follow): deliver to the original requesterUndo(Follow): deliver to the target userLike: deliver to the status authorUndo(Like): deliver to the status authorAnnounce: deliver to the target userUndo(Announce): deliver to the target userDelete: deliver to affected usersUpdate(Person): update cached remote actor infoBlock: deliver to the blocked user
Acceptance Criteria:
- All listed activity types are correctly parsed and routed
- Invalid JSON returns 400
- Unknown activity types are gracefully ignored (return 202)
- Activities targeting non-existent local users return 404
- Integration test: simulate an incoming Create(Note) from a remote instance
WI-3.8: Implement outgoing activity delivery (HTTP POST)
Description: Deliver activities to remote Fediverse instances via signed HTTP POST requests.
What should be done:
- In the Federation Canister
send_activityhandler, when the target is a remote actor:- Resolve the remote actor’s inbox URL (fetch actor profile if not cached)
- Serialize the activity as JSON-LD
- Set ActivityPub
to/ccaddressing based on visibility:Public:to: [as:Public],cc: [followers collection]Unlisted:to: [followers collection],cc: [as:Public]FollowersOnly:to: [followers collection], noas:PublicDirect:to: [mentioned actors only], nocc
- Sign the request using the sender’s RSA key (WI-3.5)
- Send the HTTP POST via
ic_cdk::api::management_canister::http_request - Handle retries for transient failures (e.g., 5xx responses)
- Implement delivery to shared inboxes when multiple recipients share the same instance
- Handle delivery failures gracefully (log, do not block the caller)
Acceptance Criteria:
- Activities are delivered to remote inboxes via signed HTTP POST
- Shared inbox optimization works (one request per remote instance)
- Transient failures are retried (up to a configurable limit)
- Permanent failures (4xx) are not retried
- The caller is not blocked by slow remote deliveries
to/ccfields correctly reflect the status visibility level
WI-3.9: Implement remote actor resolution and caching
Description: Fetch and cache remote actor profiles for use in activity routing and display.
What should be done:
- Implement a remote actor resolver in the Federation Canister:
- Given a remote actor URI, perform WebFinger lookup to find the actor URL
- Fetch the actor profile via HTTP GET with
Accept: application/activity+json - Parse the actor profile to extract: display name, summary, public key, inbox URL, followers/following URLs, icon
- Cache the actor profile in stable memory with a TTL (e.g., 24 hours)
- Provide a method for User Canisters to request remote actor info (for display in feeds)
Acceptance Criteria:
- Remote actor profiles are fetched and cached
- Cached entries expire after the TTL
- Invalid actor URIs return a descriptive error
- The resolver handles redirects and content negotiation
- Unit test: mock a remote actor endpoint, verify parsing
WI-3.10: Implement NodeInfo endpoint
Description: Serve the NodeInfo endpoint so remote instances and monitoring tools can discover Mastic’s software and protocol information.
What should be done:
- Handle
GET /.well-known/nodeinfoinhttp_request:- Return a JSON document with a link to the NodeInfo 2.0 schema URL
- Handle
GET /nodeinfo/2.0inhttp_request:- Return NodeInfo 2.0 JSON with: software name (“mastic”), version, protocols ([“activitypub”]), open registrations status, usage statistics (total users, active users, local posts)
- Fetch statistics from the Directory Canister
Acceptance Criteria:
GET /.well-known/nodeinforeturns a valid link to the NodeInfo endpointGET /nodeinfo/2.0returns valid NodeInfo 2.0 JSON- Statistics reflect actual counts from the Directory Canister
- Integration test: deploy canisters, query NodeInfo, verify response
WI-3.11: Integration tests for federation flows
Description: Write integration tests that exercise the full federation flows, verifying interoperability with the ActivityPub protocol.
What should be done:
- Test UC13 (Receive Updates from Fediverse): Simulate a remote instance
sending a
Create(Note)activity, verify it appears in the local user’s feed - Test UC14 (Interact with Mastic from Web2): Simulate a local user publishing a status, verify the Federation Canister produces a correctly signed HTTP request with the right ActivityPub payload
- Test WebFinger: Query WebFinger for a local user, verify the JRD
- Test Actor Profile: Fetch a local user’s actor profile, verify the Person object
- Test Collections: Fetch outbox/followers/following collections, verify pagination
- Test HTTP Signature round-trip: Sign a request, verify it passes validation
- Test incoming Follow from remote: Simulate a remote Follow, verify the local user gets a new follower
Acceptance Criteria:
- All federation flows pass as integration tests
- Tests run in CI via
just integration_test - Tests simulate remote instances by crafting raw HTTP requests with valid signatures
- Each test is independent and can run in isolation
Milestone 4 - SNS Launch
Duration: 1 month
Goal: Launch Mastic on the Service Nervous System (SNS) to establish fully decentralised, community-driven governance. Token holders can vote on proposals for moderation, policy changes, canister upgrades, and treasury management.
User Stories: None (infrastructure milestone)
Prerequisites: Milestone 3 completed, all canisters deployed and stable on mainnet.
Work Items
WI-4.1: Prepare SNS configuration (sns_init.yaml)
Description: Define the SNS initialization parameters, token distribution,
and governance model for the Mastic DAO in the canonical sns_init.yaml
configuration file.
What should be done:
- Create
sns_init.yamlwith all required parameters:- Project metadata: name, description, logo, URL
- NNS proposal text: title, forum URL, summary
- Fallback controllers: principal IDs that regain control if the swap fails (critical — without these the dapp becomes uncontrollable)
- Dapp canisters: Directory and Federation canister IDs to decentralize (User Canisters are managed by Directory, not directly by SNS Root)
- Token configuration: name, symbol, transaction fee, logo
- Governance parameters:
- Proposal rejection fee
- Initial voting period (>= 4 days recommended)
- Maximum wait-for-quiet deadline extension
- Minimum neuron creation stake
- Minimum dissolve delay for voting (>= 1 month)
- Dissolve delay bonus (duration + percentage)
- Age bonus (duration + percentage)
- Reward rate (initial, final, transition duration)
- Token distribution:
- Developer neurons with dissolve delay (>= 6 months) and vesting period (12-48 months) to signal long-term commitment
- Seed investor neurons (if any) with vesting
- Treasury allocation (DAO-controlled)
- Swap allocation (sold during decentralization swap)
- Total supply must equal the sum of all allocations
- Decentralization swap parameters:
minimum_participants(100-200 recommended, not too high)- Minimum/maximum direct participation ICP
- Per-participant minimum/maximum ICP
- Duration (3-7 days recommended)
- Neurons fund participation (true/false)
- Vesting schedule for swap neurons (events + interval)
- Confirmation text (legal disclaimer)
- Restricted countries list
- Validate the configuration with
dfx sns init-config-file validate - Document the governance model and tokenomics rationale in
docs/src/governance.md - Study successful SNS launches (OpenChat, Hot or Not, Kinic) for parameter ranges the NNS community accepts
Acceptance Criteria:
sns_init.yamlpassesdfx sns init-config-file validate- Token distribution adds up to the total supply exactly
- Developer neurons have non-zero dissolve delay and vesting period
- Governance parameters are reasonable (voting period >= 4 days, quorum defined, rejection fee set)
- Fallback controller principals are defined
- Documentation explains tokenomics and governance model clearly
WI-4.2: Implement SNS-compatible canister upgrade path
Description: Ensure the Directory and Federation canisters can be upgraded through SNS proposals, and that User Canisters (dynamically created) can be batch-upgraded by the Directory Canister.
What should be done:
- Verify
pre_upgradeandpost_upgradehooks correctly serialize and deserialize all state for Directory and Federation canisters - For User Canisters: verify
wasm-dbmsstable memory survives upgrades - Implement
set_sns_governanceon the Directory Canister:- Accept a principal ID for the SNS governance canister
- Only callable by canister controllers (before SNS launch) or by the already-set governance principal
- Can only be set once (trap on second call)
- Implement a
require_governance(caller)guard for governance-gated methods - Implement
upgrade_user_canistersmethod on the Directory Canister:- Accept new User Canister WASM as argument
- Callable only by SNS governance (via proposal)
- Iterate over all registered User Canisters
- Call
install_codewith modeUpgradefor each - Track progress and report failures (individual failures must not block the batch)
- Test upgrade paths with state preservation
Acceptance Criteria:
- All canister state survives an upgrade cycle
set_sns_governancecan only be called once by a controller- Governance-gated methods reject unauthorized callers
- The Directory Canister can batch-upgrade all User Canisters
- Upgrade failures for individual User Canisters do not block the batch
- Integration test: deploy, populate state, upgrade, verify state preserved
WI-4.3: Implement SNS-governed moderation proposals
Description: Transition moderation actions from direct moderator calls to SNS proposal-based governance. The SNS governance canister becomes the sole authority for moderation.
What should be done:
- Implement a generic proposal execution interface on the Directory Canister:
- Accept proposals from the SNS governance canister
- Parse proposal payloads to determine the action
- Supported proposal types:
AddModerator: add a principal to the moderator listRemoveModerator: remove a principal from the moderator listSuspendUser: suspend a user by handleUnsuspendUser: reactivate a suspended userUpdatePolicy: update instance moderation policies (e.g., content rules text)
- Restrict existing direct
add_moderator,remove_moderator, andsuspendmethods to the SNS governance canister principal only (no longer callable by individual moderators directly)
Acceptance Criteria:
- Moderation actions can only be executed via SNS proposals
- The Directory Canister correctly parses and executes each proposal type
- Invalid proposal payloads are rejected with a descriptive error
- The SNS governance canister principal is the only authorized caller for moderation methods
- Integration test: simulate a proposal execution, verify the action is applied
WI-4.4: Implement UnsuspendUser flow
Description: Add the ability to reactivate a suspended user account via SNS governance.
What should be done:
- Implement
unsuspendmethod on the Directory Canister:- Authorize the caller (SNS governance canister only)
- Remove the suspended flag from the user record
- Notify the User Canister to resume operations
- Optionally send an
Undo(Delete)orUpdate(Person)activity to re-announce the user to followers
- Define
UnsuspendArgs,UnsuspendResponsein thedidcrate
Acceptance Criteria:
- A suspended user can be reactivated via the
unsuspendmethod - Only the SNS governance canister can call
unsuspend - After unsuspension, the user can interact with their User Canister again
- The user reappears in
search_profilesresults - Integration test: suspend, then unsuspend, verify the user is active
WI-4.5: SNS testflight on local replica and mainnet
Description: Deploy a testflight (mock) SNS to validate the full governance flow before submitting the real NNS proposal. This catches configuration issues early, before they are visible to the NNS community.
What should be done:
- Local testflight:
- Deploy NNS canisters locally using
sns-testingrepo tooling - Deploy Mastic canisters locally
- Deploy a local testflight SNS using
sns_init.yaml - Test: submit a proposal to upgrade the Directory Canister, vote, verify upgrade succeeds
- Test: submit a moderation proposal, vote, verify action is applied
- Test: batch-upgrade User Canisters via proposal
- Deploy NNS canisters locally using
- Mainnet testflight:
- Deploy a mock SNS on mainnet (does not run a real swap)
- Verify governance flows: proposal submission, voting, execution
- Verify canister upgrade path end-to-end
- Verify User Canister batch upgrade
Acceptance Criteria:
- Local testflight passes all governance flow tests
- Mainnet testflight demonstrates working proposal → vote → execute cycle
- No issues discovered that would block the real launch
WI-4.6: SNS deployment and decentralization swap
Description: Submit the NNS proposal, transfer canister control to SNS Root, and execute the decentralization swap. This follows the 11-stage SNS launch process.
What should be done:
- Pre-submission:
- Add NNS Root (
r7inp-6aaaa-aaaaa-aaabq-cai) as co-controller of the Directory and Federation canisters usingdfx sns prepare-canisters add-nns-root - Final validation of
sns_init.yaml - Call
set_sns_governanceon the Directory Canister with the expected SNS governance canister ID (set after SNS-W deploys it)
- Add NNS Root (
- Submit NNS proposal:
dfx sns propose --network ic --neuron $NEURON_ID sns_init.yaml- This is irreversible once submitted — double-check all parameters
- During swap (3-7 days):
- Monitor swap participation and ICP raised
- Note: six governance proposal types are restricted during the swap
(
ManageNervousSystemParameters,TransferSnsTreasuryFunds,MintSnsTokens,UpgradeSnsControlledCanister,RegisterDappCanisters,DeregisterDappCanisters) — do not plan operations requiring these
- Post-swap finalization:
- Verify all canisters are controlled solely by SNS Root
- Verify token holders can submit and vote on proposals
- Document the post-swap governance workflow (how to submit proposals, vote, and execute upgrades)
Acceptance Criteria:
- The NNS proposal is submitted and adopted by the community
- SNS-W deploys all SNS canisters (Governance, Ledger, Root, Swap, Index, Archive)
- SNS Root becomes sole controller of Directory and Federation canisters
- The decentralization swap completes successfully (meets minimum participants and ICP thresholds)
- Token holders can submit and vote on proposals
- Post-swap documentation is published
WI-4.7: Integration tests for SNS governance flows
Description: Write integration tests that validate the SNS governance integration.
What should be done:
- Test proposal execution: Simulate an SNS proposal to add a moderator, verify the moderator is added
- Test canister upgrade via SNS: Simulate an upgrade proposal, verify state is preserved
- Test User Canister batch upgrade: Upgrade all User Canisters via the Directory, verify state
- Test suspend/unsuspend via proposal: Simulate suspend and unsuspend proposals
- Test unauthorized access: Verify that direct moderation calls (not from SNS governance) are rejected
- Test
set_sns_governance: Verify it can only be set once and only by controllers
Acceptance Criteria:
- All governance flows pass as integration tests
- Tests simulate SNS governance canister calls
- Each test is independent and can run in isolation
- Tests run in CI via
just integration_test