Aptork is a Crystal ActivityPub toolkit inspired by Fedify. It provides a small framework surface for building federated apps:
- ActivityStreams JSON-LD vocabulary helpers,
- Fedify-style
FederationandContextobjects, - actor/object/outbox dispatchers,
- followers/following/inbox/custom collection dispatchers,
- typed inbox listeners,
- framework-agnostic request routing,
Context#send_activitydelivery,- followers-recipient expansion for delivery,
- queued outbound delivery with retry helpers,
- queued inbound inbox processing with retry helpers,
- inbox forwarding with original-body delivery,
- actor key-pair dispatchers and RSA HTTP signing,
- resolver-backed inbox RSA signature verification,
- signed-fetch access control for actor/object/collection GET routes,
- remote document loading, object lookup, and collection traversal,
- FEP-ef61 portable
ap://did...IDs with compatible gateway routing, - authenticated document loaders for signed fetch,
- in-memory, Redis, and SQL (SQLite/PostgreSQL) KV and queue primitives,
- OpenMetrics/Prometheus metrics telemetry exporter,
- WebFinger handle mapping, alias mapping, link customization, and NodeInfo document builders,
- NodeInfo client lookup with typed parsing helpers,
- testing capture helpers,
- injectable HTTP/signature hooks for tests,
- ForgeFed repository, project, branch, commit, tag, push, ticket, tracker, ticket dependency, merge request, typed activity, and strict validation helpers,
- marketplace offer/service/listing and FEP-0837 proposal/agreement builder and validation helpers,
- a
FEDERATION.md(FEP-67ff) describing the implementation.
It is distributed under the 0BSD license. It is not a full Fedify port yet. The current implementation focuses on the core shape and outbound/server-side building blocks.
dependencies:
aptork:
path: ./shards/aptorkrequire "aptork"Aptork.federation creates a federation and configures it with a Crystal-style
DSL. The block uses the federation as its implicit receiver, while still
accepting an explicit block argument when preferred:
federation = Aptork.federation("https://example.com") do
actor "/users/{identifier}" do |ctx, identifier|
Aptork.actor(
"Person",
ctx.get_actor_uri(identifier),
identifier,
ctx.get_inbox_uri(identifier),
ctx.get_outbox_uri(identifier)
).as(Aptork::JsonMap?)
end
inbox "/users/{identifier}/inbox", "/inbox" do |routes|
routes.with_idempotency
routes.on "Create" do |_ctx, activity|
puts activity["id"]?
end
end
end
ctx = federation.create_context
actor = ctx.actor("alice")Federation#handle provides a small framework-neutral adapter target. Web
frameworks can translate their native requests into Aptork::Request and return
Aptork::Response. For Crystal's standard HTTP server, use
Aptork.request_from_http and Aptork.write_http_response around
Federation#fetch, matching Fedify's custom middleware integration pattern.
response = federation.handle(Aptork::Request.new(
"GET",
"/users/alice",
headers: {"Accept" => Aptork::FEDERATION_JSONLD_CONTENT_TYPE}
))
response.status
response.headers
response.bodyserver = HTTP::Server.new do |context|
request = Aptork.request_from_http(context.request)
response = federation.fetch(
request,
on_not_found: ->(_request : Aptork::Request) {
Aptork::Response.new(404, {"Content-Type" => "text/plain"}, "web route not found")
},
on_not_acceptable: ->(_request : Aptork::Request) {
Aptork::Response.new(406, {"Content-Type" => Aptork::FEDIFY_TEXT_CONTENT_TYPE}, "Not Acceptable")
}
)
Aptork.write_http_response(response, context.response)
endFederation#fetch is an alias for handle with Fedify-style request fallback
callbacks. Use these callbacks from framework middleware when normal web routes
share paths with ActivityPub routes. on_not_found also applies to
federation-managed misses such as unresolved WebFinger resources, and
on_unauthorized can customize access-control denials.
response = federation.fetch(
Aptork::Request.new("GET", "/users/alice", headers: {"Accept" => "text/html"}),
on_not_found: ->(request : Aptork::Request) {
app_route(request)
},
on_not_acceptable: ->(request : Aptork::Request) {
response = app_route(request)
response.status == 404 ? Aptork::Response.new(
406,
{"Content-Type" => Aptork::FEDIFY_TEXT_CONTENT_TYPE, "Vary" => "Accept, Signature"},
"Not Acceptable"
) : response
},
on_unauthorized: ->(_request : Aptork::Request) {
Aptork::Response.new(403, {"Content-Type" => "text/plain"}, "forbidden")
}
)Currently routed:
GET/HEAD /.well-known/webfinger?resource=acct:user@example.comGET/HEAD /.well-known/nodeinfoGET/HEAD/POST /.well-known/apgateway/{did...}/{path}GET/HEAD /nodeinfo/2.1whenset_nodeinfo_dispatcheris configured- actor GET/HEAD via
set_actor_dispatcher - object GET/HEAD via
set_object_dispatcher - outbox GET/HEAD via
set_outbox_dispatcher - outbox page GET/HEAD via
?page=N&size=N - followers/following/inbox/custom collection GET/HEAD via collection dispatchers
- inbox POST via
set_actor_dispatcherandset_inbox_listeners - outbox POST via
set_outbox_listeners
ActivityPub GET routes perform Fedify-style Accept negotiation and return
406 Not Acceptable when HTML or XHTML is the preferred media type, or when
the request does not explicitly accept a JSON ActivityPub representation.
Missing Accept is treated like Fedify's wildcard fallback and is not enough
to match an ActivityPub route by itself. Compatible media types are
application/activity+json, application/ld+json, and application/json,
all with a positive q value. As in Fedify, an ActivityPub media type still
wins when it has higher quality than HTML. Negotiated ActivityPub 200
responses use Fedify's application/activity+json content type and set
Vary: Accept; default 406 responses mirror Fedify's Not Acceptable body
with Vary: Accept, Signature. HEAD follows the same routing and negotiation
as GET but returns an empty body. WebFinger and NodeInfo use their own JSON
content types.
WebFinger JRD and WebFinger 410 Gone responses include
Access-Control-Allow-Origin: *, matching Fedify's browser-friendly discovery
behavior. Tombstoned WebFinger responses use Fedify's empty-body 410 shape
without a content type.
When handle_host differs from the canonical ActivityPub origin, WebFinger
aliases follow Fedify's shape: handle-host acct: lookups include the actor URI
but do not add the canonical-origin acct: alias, while canonical acct: and
actor-URI lookups still point clients back to the handle host.
Fedify-style inbox POST routing requires an actor dispatcher. Personal inbox
POSTs return 404 when the recipient actor cannot be dispatched or has been
tombstoned; shared inbox POSTs also require the dispatcher to be configured.
For accepted inbox POSTs, Aptork mirrors Fedify's route-result responses:
processed and unsupported activities return an empty 202, duplicate
activities return 202 Activity <id> has already been processed., enqueued
activities return 202 Activity is enqueued., missing activity actors return
400, and listener failures return 500.
Malformed JSON request bodies return Fedify's 400 Invalid JSON. response.
When an inbox POST request accepts ActivityPub or JSON, these text responses set
Vary: Accept, matching Fedify's middleware negotiation header.
Syntactically valid JSON bodies that are not ActivityPub Activity objects return
400 Invalid activity.. Array-valued actor properties are accepted when at
least one actor id can be extracted from the array.
These inbox POST plain-text responses use Fedify's
text/plain; charset=utf-8 content type.
Like Fedify, malformed inbox bodies notify the configured inbox error handler
before the 400 response is returned.
Actor dispatchers may return an ActivityStreams Tombstone for a deleted local
actor. Aptork responds to the actor route with 410 Gone and the serialized
tombstone body, and WebFinger for the same account also returns an empty-body
410 Gone. Tombstone detection accepts compact Tombstone, expanded
ActivityStreams IDs, and array-valued type fields.
Like Fedify, Aptork logs aptork.federation.actor warnings when a dispatched
actor omits or mismatches the id, configured inbox, outbox, followers,
following, liked, featured, featured-tags, publicKey, or assertionMethod
properties. Use the matching ctx.get_*_uri and ctx.get_actor_key_pairs
helper values in returned actors to keep route metadata consistent.
federation.set_actor_dispatcher("/users/{identifier}", ->(ctx : Aptork::Context, identifier : String) do
deleted_at = deleted_actor_timestamp(identifier)
if deleted_at
Aptork.tombstone(ctx.get_actor_uri(identifier), "Person", deleted_at).as(Aptork::JsonMap?)
else
nil
end
end)Collections can be returned whole or paged:
collection = Aptork.paginated_ordered_collection(
"https://example.com/users/alice/outbox",
activities
)
page = Aptork.paginated_ordered_collection(
"https://example.com/users/alice/outbox",
activities,
page: 2,
size: 20
)Outbox request routing uses the same helper when page and size query
parameters are present.
For scalable collections, register a cursor dispatcher:
federation.set_outbox_page_dispatcher(
"/users/{identifier}/outbox",
->(ctx : Aptork::Context, identifier : String, cursor : String?, size : Int32) do
items = load_outbox_items(identifier, cursor, size)
Aptork::CollectionPageResult.new(
items,
next_cursor: next_cursor_for(items),
prev_cursor: cursor,
total_items: count_outbox_items(identifier),
first_cursor: "start"
)
end
)If an outbox cursor dispatcher returns nil, Aptork mirrors Fedify's
onNotFound collection behavior and returns 404 for that outbox request.
Fedify also exposes actor collections beyond the outbox. Aptork supports the same server-side shape for followers, following, inbox, liked, featured, featured tags, and named custom collections:
federation.set_followers_dispatcher(
"/users/{identifier}/followers",
->(_ctx : Aptork::Context, identifier : String) do
load_followers(identifier)
end
)
# Cursor followers dispatchers can receive a normalized ?base-url= filter so
# FEP-8fcf synchronization views can be filtered in storage instead of after
# loading every follower. Aptork still applies an in-memory safety filter.
federation.set_followers_dispatcher(
"/users/{identifier}/followers",
->(_ctx : Aptork::Context, identifier : String, cursor : String?, size : Int32, base_url : String?) do
load_followers_page(identifier, cursor, size, base_url)
end
)
# If a cursor followers dispatcher returns nil for cursor nil, send_activity
# falls back to first_cursor and walks every page when collecting recipients.
federation.set_followers_dispatcher(
"/users/{identifier}/followers",
->(_ctx : Aptork::Context, params : Hash(String, String), cursor : String?, size : Int32) do
cursor.nil? ? nil : load_followers_page(params["identifier"], cursor, size)
end
)
federation.set_collection_first_cursor("followers", ->(_ctx, _params) { "".as(String?) })
federation.set_following_dispatcher(
"/users/{identifier}/following",
->(_ctx : Aptork::Context, identifier : String) do
load_following(identifier)
end
)
# Following, liked, featured, and featured-tags dispatchers also support the
# same cursor page shape as Fedify's built-in actor collections.
federation.set_following_dispatcher(
"/users/{identifier}/following",
->(_ctx : Aptork::Context, identifier : String, cursor : String?, size : Int32) do
load_following_page(identifier, cursor, size)
end
)
federation.set_inbox_dispatcher(
"/users/{identifier}/inbox",
->(_ctx : Aptork::Context, identifier : String) do
load_received_activities(identifier)
end
)
federation.set_liked_dispatcher(
"/users/{identifier}/liked",
->(_ctx : Aptork::Context, identifier : String) do
load_liked_objects(identifier)
end
)
federation.set_featured_dispatcher(
"/users/{identifier}/featured",
->(_ctx : Aptork::Context, identifier : String) do
load_featured_objects(identifier)
end
)
federation.set_featured_tags_dispatcher(
"/users/{identifier}/tags",
->(_ctx : Aptork::Context, identifier : String) do
load_featured_tags(identifier)
end
)
federation.set_collection_dispatcher(
"bookmarks",
"/users/{identifier}/collections/bookmarks",
->(_ctx : Aptork::Context, identifier : String) do
load_bookmarks(identifier)
end
)
federation.set_ordered_collection_dispatcher(
"featured-archive",
"/users/{identifier}/collections/featured-archive",
->(_ctx : Aptork::Context, identifier : String) do
load_featured_archive(identifier)
end
)
federation.set_collection_dispatcher(
"repo-tags",
"/repos/{owner}/{repo}/tags",
->(_ctx : Aptork::Context, params : Hash(String, String)) do
load_repo_tags(params["owner"], params["repo"])
end
)
federation.set_collection_page_dispatcher(
"stars",
"/repos/{owner}/{repo}/stars",
->(_ctx : Aptork::Context, params : Hash(String, String), cursor : String?, size : Int32) do
load_star_page(params["owner"], params["repo"], cursor, size)
end
)
federation
.set_collection_item_type("stars", "Person")
.set_collection_first_cursor("stars", ->(_ctx, _params) { "start" })
.set_collection_last_cursor("stars", ->(_ctx, _params) { "end" })
.set_collection_counter("stars", ->(_ctx, _params) { count_stars })
federation.configure_collection("stars")
.item_type("Person")
.set_first_cursor(->(_ctx, _params) { "start" })
.set_last_cursor(->(_ctx, _params) { "end" })
.set_counter(->(_ctx, _params) { count_stars })
.filter(->(_ctx, item) {
item["id"].as_s.starts_with?("https://remote.example/")
})
.authorize(->(_ctx, _request, verification, _identifier, params) {
verification.verified && can_read_stars?(params["owner"], params["repo"])
})Built-in actor collections and set_ordered_collection_dispatcher return
OrderedCollection / OrderedCollectionPage. set_collection_dispatcher
matches Fedify's unordered custom collection API and returns Collection /
CollectionPage. Served custom collection document IDs use the same canonical
origin as ctx.get_collection_uri. Array dispatchers support page and size query parameters.
Page dispatchers receive all URI parameters plus cursor and size, and return
CollectionPageResult for cursor pagination. Use
set_ordered_collection_page_dispatcher for ordered cursor collections.
If a cursor page dispatcher returns nil, Aptork now mirrors Fedify's
onNotFound behavior and returns 404 for that collection request.
Cursor collection links follow Fedify's request-URL behavior: first, last,
prev, next, and page partOf preserve unrelated query parameters while
replacing or removing only cursor.
Collection metadata callbacks mirror Fedify's setFirstCursor, setLastCursor,
setCounter, and filterPredicate shape for whole collection responses.
For array and cursor dispatchers, setCounter controls root collection
totalItems; page responses still describe the concrete page items. Filtered
collections preserve the dispatcher/counter-provided totalItems rather than
replacing it with the filtered item length, matching Fedify's separate counter
and item-filtering behavior.
configure_collection groups metadata, filtering, and authorization callbacks
for a Fedify-style chain, while the direct set_collection_* methods remain
available.
Inbox and outbox listener matching follows Fedify's activity-class shape.
Register a specific activity type with a compact string such as "Follow" or
"Create", a canonical type ID such as
"https://www.w3.org/ns/activitystreams#Create", or a vocab class such as
Aptork::Vocab::Create. Register "Activity" to receive every incoming
activity. The legacy on_any helper remains available as an explicit wildcard.
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.on(Aptork::Vocab::Follow, ->(ctx : Aptork::Context, follow : Aptork::Vocab::Follow) do
accept_follow(ctx, follow)
nil
end)
.on("Activity", ->(_ctx : Aptork::Context, activity : Aptork::JsonMap) do
audit_inbox_activity(activity)
nil
end)Aptork normalizes expanded type IDs before matching and also matches ActivityPub
documents whose type is an array, so a payload with
["https://www.w3.org/ns/activitystreams#Create", "Activity"] reaches both
"Create" and "Activity" listeners.
When the listener parameter is typed as the vocab class, Aptork parses the
incoming JSON-LD before calling the handler. Use JsonMap in the lambda
signature when you want the raw payload instead.
Listener dispatch also follows vocabulary inheritance for common Fedify cases:
an Invite reaches Offer/Aptork::Vocab::ActivityOffer listeners,
TentativeAccept reaches Accept, TentativeReject reaches Reject, and
Block reaches Ignore.
Fedify exposes outbox listeners for client-to-server POST requests to an
actor outbox. Aptork mirrors that shape with local authorization and typed
activity handlers:
federation.set_outbox_listeners("/users/{identifier}/outbox")
.authorize(->(ctx : Aptork::Context, identifier : String) do
token = ctx.inbound_request.try(&.headers["Authorization"]?)
token == "Bearer #{identifier}"
end)
.on("Create", ->(ctx : Aptork::Context, activity : Aptork::JsonMap) do
persist_outbox_activity(ctx.identifier, activity)
ctx.send_activity(ctx.identifier.not_nil!, "followers", activity)
nil
end)Outbox POST authorization is local application logic; Aptork does not apply
inbox HTTP Signature verification to client-to-server posts. Like Fedify, the
addressed actor must exist and the posted activity must include an actor
matching the outbox actor URI; when the dispatched actor omits id, Aptork
falls back to ctx.get_actor_uri(identifier) like Fedify. Missing or
mismatched activity actors return 400.
If an outbox route is configured for GET collection dispatch but no outbox
listeners are configured, POST returns Fedify's 405 Method not allowed.
response with Allow: GET, HEAD.
When actor is an array, every extracted actor id must match the outbox actor,
matching Fedify's actorIds.every(...) rule.
Unsupported activity types still return 202, while listener failures notify
configured outbox error handlers and return 500.
Malformed JSON request bodies return Fedify's 400 Invalid JSON. response.
Syntactically valid JSON bodies that are not ActivityPub Activity objects return
400 Invalid activity.. Malformed bodies and actor validation failures notify
the configured outbox listener error handler before the 400 response is
returned, matching Fedify's outbox error callback behavior. These outbox POST
plain-text responses use Fedify's text/plain; charset=utf-8 content type and
set Vary: Accept when the request accepts ActivityPub or JSON.
Inside these listeners ctx.identifier is the matched outbox route identifier,
matching Fedify's OutboxContext.identifier; ctx.outbox_identifier remains as
an explicit Aptork alias. Listeners explicitly decide whether to persist, send,
or forward the activity. Outbox listener type matching has the same vocab-class,
type-ID, "Activity" catch-all, and array type support as inbox listener
matching.
Fedify caches processed inbox activities so duplicate delivery does not invoke
listeners repeatedly. Like Fedify 2.x, Aptork enables the per-inbox strategy
by default when the federation has a KV store:
store = Aptork::MemoryKvStore.new
federation = Aptork::Federation.create("https://example.com", kv: store)
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.on("Create", ->(_ctx : Aptork::Context, _activity : Aptork::JsonMap) do
nil
end)The default strategy is per-inbox, matching Fedify's current behavior: the
same activity id may be processed once for Alice's inbox and once for Bob's
inbox. Use with_idempotency to change TTL, or use per-origin or global
for broader deduplication:
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.with_idempotency("per-origin")Custom strategies can return a cache key or nil to skip idempotency for a
specific activity:
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.with_idempotency(Time::Span.new(hours: 24), ->(ctx : Aptork::Context, activity : Aptork::JsonMap) do
if activity["type"]?.try(&.as_s?) == "Follow"
nil
else
id = activity["id"]?.try(&.as_s?)
inbox = ctx.recipient_identifier || "shared"
id ? "#{ctx.origin}\n#{id}\n#{inbox}" : nil
end
end)Inbox listener contexts expose ctx.recipient_identifier for personal inbox
delivery and nil for shared inbox delivery.
Verified inbox POSTs can be enqueued before listeners run. Invalid or unverified activities are rejected immediately and are not queued:
queue = Aptork::InProcessMessageQueue.new
policy = Aptork::RetryPolicy.new(max_attempts: 5)
federation = Aptork::Federation.create(
"https://example.com",
inbox_queue: queue,
inbox_retry_policy: policy
)
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.with_idempotency
.on("Create", ->(_ctx : Aptork::Context, activity : Aptork::JsonMap) do
handle_create(activity)
nil
end)
.on_error(->(ctx : Aptork::Context, error : Exception) do
log_inbox_error(ctx.recipient_identifier, error)
nil
end)
federation.create_context.process_queued_inbox_activities(limit: 10)Queued inbox processing retries listener failures with the configured retry
policy. Idempotency is marked only after listeners complete successfully, so
failed queued attempts can retry.
InboxListeners#on_error reports listener exceptions for both synchronous and
queued processing, and malformed POST bodies before their Fedify-style 400
responses. Without a scoped handler Aptork falls back to the federation-wide
on_error handler for listener errors and malformed-body notifications, while
unhandled listener exceptions otherwise re-raise.
Manual routing follows Fedify's queue behavior: if an inbox queue is configured,
Context#route_activity enqueues by default. Pass immediate: true to invoke
listeners synchronously:
ctx.route_activity("alice", activity)
ctx.route_activity("alice", activity, Aptork::RouteActivityOptions.new(immediate: true))
ctx.route_activity(
"alice",
activity,
Aptork::RouteActivityOptions.new(document_loader: loader, context_loader: context_loader)
)Use route_activity_result when tests or application code need Fedify-style
outcomes instead of a boolean:
case ctx.route_activity_result("alice", activity)
when Aptork::RouteActivityResult::Success
mark_inbox_processed(activity)
when Aptork::RouteActivityResult::Enqueued
mark_inbox_queued(activity)
when Aptork::RouteActivityResult::UnsupportedActivity
audit_unsupported(activity)
endManual routing also follows Fedify's trust model. Unsigned activities are
dereferenced by id before listeners see them; Aptork rejects missing actors,
fetched id mismatches, and fetched actors whose origin differs from the fetched
activity. Use trusted: true only for activity maps that have already passed
your HTTP signature, Object Integrity Proof, or equivalent queue boundary:
ctx.route_activity("alice", verified_activity, Aptork::RouteActivityOptions.new(trusted: true))Fedify exposes forwardActivity() for forwarding an incoming activity without
rewriting the activity payload. Aptork mirrors that shape with
Context#forward_activity:
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.on("Create", ->(ctx : Aptork::Context, activity : Aptork::JsonMap) do
ctx.forward_activity(
activity,
Aptork::ForwardActivityOptions.new(skip_if_unsigned: true)
)
nil
end)The automatic overload reads local actor ids from top-level to, cc, and
audience, plus embedded object.to, object.cc, and object.audience.
Each addressed local actor is used as the forwarder and the activity is
forwarded to that actor's followers. Use the explicit overload when you already
know the forwarder or target collection:
ctx.forward_activity("alice", "followers", activity)
ctx.forward_activity("alice", recipients, activity)
ctx.forward_activity("alice", remote_actor_json, activity)
ctx.forward_activity({username: "alice"}, remote_actor_json, activity)Like send_activity, the explicit-recipient forwarding overload can take one
or more Aptork::ActorKeyPair values when the forwarder keys are already
available:
ctx.forward_activity(
key_pair,
remote_actor_json,
activity,
Aptork::ForwardActivityOptions.new(ordering_key: "alice")
)Explicit forward targets can be Recipient values, raw ActivityPub actor
documents, typed Aptork::Vocab::Actor values, or arrays of either actor shape.
Forwarding posts the original inbox request body when one is available, while
the forwarding HTTP POST can still be signed by the local forwarder key. This
preserves embedded Object Integrity Proofs. Without such a proof, remote servers
may still reject the forwarded activity; use skip_if_unsigned: true to avoid
forwarding activities without proof or legacy signature fields.
When an outbox queue is configured, forwarding is queued by default and the
original inbox request body is preserved inside the queued task. Explicit
forwarder key pairs are serialized into queued forward tasks so workers can sign
the later HTTP POST. Pass immediate: true to bypass the queue:
ctx.forward_activity(
"alice",
"followers",
activity,
Aptork::ForwardActivityOptions.new(immediate: true)
)Inbox verification is opt-in while the signature stack is still evolving. Apps can attach a verifier and an unverified-activity callback:
federation.set_inbox_verifier(->(request : Aptork::Request, activity : Aptork::JsonMap) do
if Aptork::Signatures.verified_by_headers?(request)
Aptork::VerificationResult.new(true)
else
Aptork::VerificationResult.new(false, "missing or invalid signature")
end
end)
federation.on_unverified_activity(->(_ctx : Aptork::Context, activity : Aptork::JsonMap, result : Aptork::VerificationResult) do
puts result.reason
nil.as(Aptork::Response?)
end)The same callback can be registered from the inbox listener chain. If it returns
a Response, Aptork uses it instead of the default 401 response; returning
nil preserves the default challenge response:
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.on("Create", create_listener)
.on_unverified_activity(->(_ctx : Aptork::Context, activity : Aptork::JsonMap, result : Aptork::VerificationResult) do
quarantine(activity, result)
Aptork::Response.new(202, {"Content-Type" => "text/plain"}, "quarantined").as(Aptork::Response?)
end)Aptork::Signatures currently supports Cavage-style Signature header parsing,
digest validation, signing-string construction, and RSA-SHA256 verification. It
also includes a focused RFC 9421 HTTP Message Signatures helper surface for
RSA-PKCS#1-v1.5 SHA-256 over @method, @target-uri, @authority, host,
date, and content-digest:
headers = Aptork::Signatures.rfc9421_rsa_sha256_headers(
"post",
"https://remote.example/inbox",
activity.to_json,
key_pair
)Use Rfc9421Options for custom labels/components and nonce/tag/expires
parameters. Verification accepts Rfc9421VerifyOptions for required components,
clock skew, and deterministic test time. Federation verification reconstructs
the expected target URI from the local origin and request path before validating
@target-uri.
Structured component identifiers are preserved in the generated
Signature-Input and signature base. Aptork understands request-derived
components such as @scheme, @request-target, @query, and
@query-param;name="resource", and keeps structured field parameters on named
fields such as content-digest;sf. Aptork::Signatures.parse_accept_signatures
parses multi-member Accept-Signature challenges so callers can inspect each
candidate challenge and its parameters.
Aptork also includes Object Integrity Proof helpers inspired by Fedify's
signObject()/createProof() API. For Fedify-style proofs, use an Ed25519
actor key pair. Aptork publishes that key as a Multikey assertion method, signs
the Data Integrity hash data with Ed25519, and emits a multibase base58btc
proofValue with cryptosuite eddsa-jcs-2022:
ed25519_key = Aptork::ActorKeyPair.new(
id: "https://example.com/users/alice#multikey-1",
owner: "https://example.com/users/alice",
public_key_pem: ed25519_public_key_pem,
private_key_pem: ed25519_private_key_pem,
algorithm: "ed25519"
)
signed = Aptork::Signatures.attach_object_proof(
activity,
ed25519_key,
Aptork::ObjectProofOptions.new(created: Aptork.now)
)
valid = Aptork::Signatures.verify_object_proof?(signed, ed25519_key)The proof payload excludes the object's proof property, includes the proof
configuration without proofValue, and handles a proof object or proof array.
This is useful for ForgeFed and marketplace activities that need embedded tamper
evidence. RSA-backed local proofs remain available with cryptosuite
jcs-rsa-sha256-2026 for applications that only have RSA actor keys, but
Ed25519 Multikey proofs are the Fedify-compatible path.
When Context#send_activity or Context#enqueue_activity is used with a key
pairs dispatcher or explicit sender key pairs, Aptork automatically creates
Object Integrity Proofs for Ed25519 key pairs before recipient delivery fanout.
Immediate delivery, per-recipient queued delivery, and queued fan-out expansion
therefore send the same pre-signed activity to every recipient.
For inbound verification, Aptork first asks the configured signature key
resolver. If no key is returned, it dereferences the proof verificationMethod
with the federation document loader, checks direct Multikey documents or the
controller actor's assertionMethod, decodes Ed25519 publicKeyMultibase, and
caches validated public keys in the configured KV store.
Inbound Object Integrity Proof verification also checks attribution coverage:
valid proof key owners must cover the activity actor and nested object
actor/attributedTo identities. A ForgeFed Create(Ticket) or marketplace
proposal attributed to a second actor therefore needs a second valid proof from
that actor instead of passing with only the activity actor's proof.
For a Fedify-style built-in path, configure a signature key resolver. Aptork will
reject unsigned or invalid inbox POSTs before listeners run, verify Object
Integrity Proofs, RFC 9421 Signature-Input/Content-Digest, or the legacy RSA
Signature/Digest headers, and require the resolved key owner to match
activity.actor by default:
federation.set_signature_key_resolver(->(key_id : String) do
key = fetch_or_load_remote_key(key_id)
key ? Aptork::ActorKeyPair.new(
id: key.id,
owner: key.owner,
public_key_pem: key.public_key_pem
).as(Aptork::ActorKeyPair?) : nil
end)
federation.enable_inbox_signature_verification(
Aptork::InboxSignatureOptions.new(
require_actor_key_owner: true,
challenge_policy: Aptork::InboxChallengePolicy.new(
enabled: true,
request_nonce: true
)
)
)When challenge_policy.enabled is true, HTTP signature verification failures
return 401 with an Accept-Signature header describing the RFC 9421 signature
components Aptork accepts. Like Fedify, challenged failures use
text/plain; charset=utf-8, Cache-Control: no-store,
Vary: Accept, Signature, and the generic
Failed to verify the request signature. body. Actor/key-owner mismatch
failures are not challenged, because signing with different parameters cannot
fix impersonation. If request_nonce is enabled, Aptork stores a one-time nonce
in the configured KV store and consumes it when a valid retry signature arrives.
set_signature_key_resolver enables verification with default options for
convenience. Use enable_inbox_signature_verification when you want to make the
policy explicit or relax actor/key-owner matching for a compatibility test.
Manual set_inbox_verifier callbacks take precedence over the built-in
resolver path.
Authorized fetch can be enforced on actor, object, and collection GET routes by
registering authorizer predicates. The predicate receives the signed request
verification result and can decide based on signer_actor:
federation.set_signature_key_resolver(->(key_id : String) do
resolve_remote_key(key_id)
end)
federation.set_actor_authorizer(
->(_ctx : Aptork::Context,
_request : Aptork::Request,
verification : Aptork::VerificationResult,
identifier : String?,
_params : Hash(String, String)) do
verification.verified &&
!!verification.signer_actor &&
!blocked?(identifier, verification.signer_actor)
end
)
federation.configure_object("Ticket")
.authorize(->(_ctx, _request, verification, _identifier, params) do
verification.verified && can_read_ticket?(params["repo"], verification.signer_actor)
end)
federation.set_collection_authorizer("featured", ->(_ctx, _request, verification, identifier, _params) do
verification.verified && can_read_featured?(identifier, verification.signer_actor)
end)If an authorizer returns false, Aptork responds with 401 Unauthorized by
default, or delegates to on_unauthorized when the request is handled through
fetch/handle with fallback callbacks. Inbox signature-challenge 401
responses keep their Accept-Signature headers and do not use this callback.
Collection authorizers run after the collection dispatcher has produced a
collection or page, matching Fedify's handleCollection order; missing
dispatcher results still return 404 before authorization.
Actor and object authorizers follow the same Fedify ordering: the dispatcher
runs first, missing resources return 404, and authorization is checked only
for an existing actor or object.
Routes without an authorizer remain public.
Inside request-derived callbacks, ctx.get_signed_key returns the verified
remote signing key and ctx.get_signed_key_owner returns its actor URI. Use
get_signed_key_owner_actor or a typed get_signed_key_owner call to resolve
the signer to an ActivityStreams actor, optionally with a per-call document
loader for mutual authorized fetch. These helpers return nil for unsigned,
invalid, or non-request contexts:
federation.set_actor_authorizer(
->(ctx : Aptork::Context, _request, _verification, _identifier, _params) do
owner = ctx.get_signed_key_owner_actor(
Aptork::GetSignedKeyOptions.new(document_loader: instance_loader)
)
owner.try(&.id) == "https://remote.example/users/bob"
end
)
person = ctx.get_signed_key_owner(Aptork::Vocab::Person)Fedify uses actor key-pair dispatchers to expose public keys and sign outbound
delivery. Aptork mirrors that shape with set_key_pairs_dispatcher and
Context#get_actor_key_pairs:
federation.set_key_pairs_dispatcher(->(ctx : Aptork::Context, identifier : String) do
owner = ctx.get_actor_uri(identifier)
[
Aptork::ActorKeyPair.new(
id: "#{owner}#main-key",
owner: owner,
public_key_pem: load_public_key(identifier),
private_key_pem: load_private_key(identifier)
),
]
end)The dispatcher receives a context whose
key_pairs_dispatcher_identifier is set to the identifier currently being
resolved. As in Fedify, calling ctx.get_actor_key_pairs from inside this
callback can recurse; Aptork logs a warning when that happens, and dispatcher
code can check the marker to avoid accidental loops.
When an actor is dispatched, Aptork adds publicKey from the first RSA key pair
unless the actor already includes one, and adds assertionMethod entries for
non-RSA keys:
actor = ctx.actor("alice")
actor["publicKey"]?You can also build key material manually:
key = ctx.get_actor_key_pairs("alice").first
public_key = Aptork.public_key(key)RSA key maps are emitted as typed CryptographicKey objects, while Ed25519 keys
are emitted as typed Multikey objects. Both parse through
Aptork::Vocab::Object.from_json_ld and are exposed on
Aptork::Vocab::Person#public_key / #assertion_methods.
Context#send_activity and queued delivery processing select the first
RSA-SHA256 key pair with a private key and sign outgoing inbox POSTs with the
legacy ActivityPub Signature header. Static transport-level signing remains
available for compatibility.
Aptork::Context mirrors the Fedify style of deriving stable URLs from route
templates:
get_actor_uri(identifier)get_inbox_uri(identifier)get_inbox_urifor shared inboxget_outbox_uri(identifier)get_followers_uri(identifier)get_following_uri(identifier)get_liked_uri(identifier)get_featured_uri(identifier)get_featured_tags_uri(identifier)get_collection_uri(name, params)get_object_uri(type, params)parse_uri(uri)
Request-derived contexts expose Fedify-style request helpers:
request/inbound_requesturlorigincanonical_originhosthostnamedata
Pass request-scoped data through handle/fetch or create_context, and use
clone(data)/with_data(data) to derive a context with replacement data:
data = Aptork.json({"tenant" => "alpha"})
response = federation.handle(request, context_data: data)
ctx = federation.create_context(context_data: data)
next_ctx = ctx.clone(Aptork.json({"tenant" => "beta"}))When the request/runtime origin differs from the public ActivityPub origin,
pass canonical_origin. Generated actor, object, inbox, outbox, collection, and
NodeInfo URLs use the canonical origin, while ctx.origin, ctx.url,
ctx.host, and ctx.hostname still describe the request origin. If handles
should use another host, pass handle_host; WebFinger accepts both the handle
host and canonical host. parse_uri accepts both runtime and canonical origins.
Like Fedify, runtime and canonical origins must be HTTP(S) origin roots without
path, query, or fragment components; accepted origins are normalized to lower
case and omit default ports.
federation = Aptork::Federation.create(
"https://internal.example:8443",
canonical_origin: "https://ap.example",
handle_host: "example.com"
)
origin = Aptork::FederationOrigin.new(
handle_host: "example.com",
web_origin: "https://ap.example"
)
federation = Aptork::Federation.create(origin)
ctx = federation.create_context
ctx.get_actor_uri("alice") # "https://ap.example/actors/alice"
federation.handle(Aptork::Request.new(
"GET",
"/.well-known/webfinger",
query: {"resource" => "acct:alice@example.com"}
))
ctx.parse_uri("https://internal.example:8443/actors/alice")
ctx.parse_uri("https://ap.example/actors/alice")Fedify's trailingSlashInsensitive option is available as
trailing_slash_insensitive. It leaves generated URLs unchanged, but incoming
route matching and parse_uri treat /foo and /foo/ as the same path:
federation = Aptork::Federation.create(
"https://example.com",
trailing_slash_insensitive: true
)Fixed actor aliases map a public path to a stable internal identifier while still using the normal actor dispatcher. This is useful for instance, bot, relay, ForgeFed, or marketplace service actors:
federation.set_actor_dispatcher("/users/{identifier}", actor_dispatcher)
federation.map_actor_alias("/bot", "bot")
ctx.get_actor_uri("bot") # "https://example.com/bot"Requests to /bot dispatch the actor identifier "bot", and WebFinger self
links use the fixed path when a handle maps to that identifier.
Object dispatchers can use get_object_uri to build dereferenceable IDs from
the registered URI template and Context#object(type, params) can dispatch a
local object by all URI parameters:
federation.set_object_dispatcher("Ticket", "/repos/{repo}/tickets/{ticket_id}", ->(ctx, values) do
Aptork.forgefed_ticket(
ctx.get_object_uri("Ticket", values),
"Bug",
"Fix it"
)
end)
ticket = ctx.object("Ticket", {"repo" => "aptork", "ticket_id" => "42"})Like Fedify's ctx.getObjectUri(Note, values), Aptork also accepts typed
vocabulary classes:
federation.set_object_dispatcher("Note", "/users/{identifier}/notes/{note_id}", ->(ctx, values) do
Aptork.note(
ctx.get_object_uri(Aptork::Vocab::Note, values),
"Hello"
)
end)
note = ctx.get_object(Aptork::Vocab::Note, {
"identifier" => "alice",
"note_id" => "1",
})
# Named tuples mirror Fedify's object-literal call shape in Crystal:
note_uri = ctx.get_object_uri(Aptork::Vocab::Note, {identifier: "alice", note_id: "1"})
note = ctx.get_object(Aptork::Vocab::Note, {identifier: "alice", note_id: "1"})
ticket_uri = ctx.get_collection_uri("tickets", {repo: "aptork"})Fedify-style getter aliases are also available. get_actor suppresses
Tombstone actors by default, or returns them with tombstone: "passthrough":
actor = ctx.get_actor("alice", Aptork::Vocab::Person)
raw_actor = ctx.get_actor("alice")
tombstone = ctx.get_actor("gone", Aptork::Vocab::Tombstone, Aptork::GetActorOptions.new(tombstone: "passthrough"))
ticket = ctx.get_object("Ticket", {"repo" => "aptork", "ticket_id" => "42"})parse_uri reverses local actor, inbox, collection, and object routes:
parsed = ctx.parse_uri("https://example.com/repos/aptork/tickets/42")
parsed = ctx.parse_uri(URI.parse("https://example.com/repos/aptork/tickets/42"))
parsed = ctx.parse_uri(nil)
parsed.try(&.type) # "object"
parsed.try(&.object_type) # "Ticket"
parsed.try(&.values) # {"repo" => "aptork", "ticket_id" => "42"}Portable contexts created by /.well-known/apgateway/{did...}/... generate
ap://did... actor, inbox, outbox, collection, and object IDs from the same
route templates. You can also create one explicitly:
portable_ctx = federation.create_context.with_portable_authority("did:key:z6M...")
portable_ctx.get_actor_uri("alice") # "ap://did:key:z6M.../users/alice"Route templates support simple expansion ({id}) and reserved expansion
({+id}) for URI-like identifiers. Actor, inbox, followers, following, liked,
featured, and featured-tags routes follow Fedify's validation and must contain
exactly one {identifier} or {+identifier} variable. Outbox routes must use
exactly one {identifier} variable, and Aptork rejects mismatched inbox/outbox
dispatcher and listener paths instead of silently replacing one route with
another. Reserved expansion routes do not match an empty tail, so paths like
/actors/ are treated as not found instead of dispatching an empty identifier,
matching Fedify's identifier callback contract. Object and custom collection
dispatchers can still use route-specific variables such as
/repos/{repo}/tickets/{ticket_id}.
Fedify's Context#lookupObject and Context#traverseCollection are represented
as raw JsonMap helpers in Aptork. Configure a document loader when you want to
control fetching, caching, tests, or authenticated fetch:
federation.set_document_loader(->(url : String) do
response = HTTP::Client.get(
url,
headers: HTTP::Headers{"Accept" => Aptork::FEDERATION_JSONLD_CONTENT_TYPE}
)
response.success? ? JSON.parse(response.body).as_h : nil
end)Like Fedify, contexts expose both document_loader and context_loader. The
context loader defaults to the document loader, but can be configured separately
for JSON-LD context expansion or tests:
federation = Aptork::Federation.create(
"https://example.com",
document_loader: document_loader,
context_loader: context_loader
)
ctx = federation.create_context(
Aptork::Request.new("GET", "/users/alice"),
context_data: Aptork.json({"tenant" => "alpha"})
)
ctx.document_loader.call("https://remote.example/users/alice")
ctx.context_loader.call("https://www.w3.org/ns/activitystreams")Use the metadata loader when your integration needs response status, headers, and content type in addition to parsed JSON:
metadata_loader = Aptork::Remote.document_loader_with_metadata(->(url : String, headers : HTTP::Headers) do
response = HTTP::Client.get(url, headers: headers)
{response.status_code, response.body, response.headers}
end)
document = metadata_loader.call("https://remote.example/users/alice")
document.try(&.content_type)
document.try(&.headers)
legacy_loader = Aptork::Remote.json_document_loader(metadata_loader)
federation.set_document_loader(legacy_loader)The built-in public and authenticated document loaders reject private-network
URLs by default, matching Fedify's SSRF protection. Requests to localhost,
loopback, link-local, RFC 1918 IPv4, carrier-grade NAT, multicast/reserved IPv4,
IPv6 loopback, link-local, and unique-local addresses are blocked before the
HTTP provider runs. Local development or trusted test harnesses can opt out
explicitly:
loader = Aptork::Remote.default_document_loader(
get_provider,
allow_private_address: true
)
federation = Aptork::Federation.create(
"https://example.com",
document_get_provider: get_provider,
allow_private_address: true
)Fedify-style outbound user-agent configuration is available for the built-in document loaders and federation document providers:
loader = Aptork::Remote.default_document_loader(
get_provider,
user_agent: "my-app/1.0"
)
federation = Aptork::Federation.create(
"https://example.com",
document_get_provider: get_provider,
user_agent: "my-app/1.0"
)Custom loaders installed with set_document_loader are responsible for their
own fetch policy.
Like Fedify's lookupObject(), LookupObjectOptions can carry a per-call
loader. The context helpers use this loader for lookup and object verification
instead of the federation default:
object = ctx.lookup_object(
"https://remote.example/notes/1",
Aptork::LookupObjectOptions.new(document_loader: loader)
)
same_object = ctx.lookup_object(URI.parse("https://remote.example/notes/1"))
repo = ctx.lookup_object(
"https://forge.example/repos/aptork",
Aptork::Vocab::Repository
)
repo.try(&.clone_uri)
offer = Aptork::Remote.lookup_object(
"https://market.example/offers/solver",
Aptork::Vocab::MarketplaceOffer,
ctx.document_loader
)
offer.try(&.price_currency)
verified = ctx.verify_activity_object(
activity,
Aptork::LookupObjectOptions.new(document_loader: loader)
)Wrap a loader with Remote.kv_cache to mirror Fedify's KV-backed kvCache
document-loader decorator:
store = Aptork::MemoryKvStore.new
loader = Aptork::Remote.kv_cache(
Aptork::Remote.default_document_loader,
store,
Aptork::DocumentCacheOptions.new(ttl: Time::Span.new(minutes: 10))
)
federation.set_document_loader(loader)
# Or wrap the federation's current loader with its configured KV store.
federation.enable_document_cache(ttl: Time::Span.new(minutes: 10))Only successful JSON documents are cached by default. Authenticated document loaders are left uncached unless you explicitly wrap them, because responses can depend on the requesting actor.
When a remote server requires authorized fetch, build an authenticated document loader for a local actor. Aptork signs GET requests with the actor's first RSA-SHA256 key pair and retries one transient transport failure, matching Fedify's idempotent authenticated document fetch behavior:
loader = ctx.get_document_loader("alice")
private_following = Aptork::Remote.lookup_object(
"https://remote.example/users/bob/following",
loader,
Aptork::LookupObjectOptions.new(cross_origin: "trust")
)Public loaders do not retry by default. Pass transient_retries when you want a
specific retry count:
loader = Aptork::Remote.default_document_loader(transient_retries: 2)The effective loader is available as ctx.document_loader, and the context
lookup helpers (lookup_webfinger, lookup_object, verify_object_reference,
verify_activity_object, lookup_nodeinfo, and traverse_collection) use it.
Inbox listener contexts use authenticated loaders automatically for personal
inboxes when key pairs are registered. Shared inboxes can opt into a
Fedify-style shared key dispatcher, usually backed by an instance actor:
jrd = ctx.lookup_webfinger(URI.parse("acct:alice@example.com"))
mailto = ctx.lookup_webfinger("mailto:juliet@example.com?subject=Hi")federation.set_document_get_provider(remote_get_provider)
federation.set_key_pairs_dispatcher(->(ctx : Aptork::Context, identifier : String) do
load_actor_keys(ctx.get_actor_uri(identifier))
end)
federation.set_inbox_listeners("/users/{identifier}/inbox", "/inbox")
.set_shared_key_dispatcher(->(_ctx : Aptork::Context) do
{identifier: "instance"}.as(Aptork::SharedInboxKey?)
end)
.on("Create", ->(ctx : Aptork::Context, activity : Aptork::JsonMap) do
object = ctx.lookup_object(activity["object"].as_s)
offers = ctx.traverse_collection("https://market.example/offers")
end)Lookup supports direct object URLs, fediverse handles, and acct: URIs:
ctx = federation.create_context
actor = ctx.lookup_object("@alice@example.com")
same_actor = ctx.lookup_object("acct:alice@example.com")
ticket = ctx.lookup_object("https://forge.example/tickets/1")FEP-ef61 ap://did... IDs are dereferenced through gateway hints in the URI or
explicit lookup options. Compatible gateway HTTP IDs under
/.well-known/apgateway/{did...}/... compare equal to their canonical ap://
IDs:
portable = Aptork.ap_uri("did:key:z6M...", "/objects/1", ["https://example.com"])
object = ctx.lookup_object(portable)
same = Aptork.ap_uri_equivalent?(
portable,
"https://example.com/.well-known/apgateway/did:key:z6M.../objects/1"
)For http/https identifiers, Aptork first fetches the URL directly. If that
misses, it falls back to WebFinger for that URL and follows the ActivityPub
self link, matching Fedify's lookupObject behavior for profile URLs:
actor = ctx.lookup_object("https://example.com/@alice")Fedify-style actor handle discovery is available for actor documents and actor
URIs. Aptork first checks WebFinger URL resources and acct: aliases, verifies
cross-origin handle aliases, and falls back to preferredUsername on actor
objects:
handle = ctx.get_actor_handle(actor)
typed_handle = ctx.get_actor_handle(Aptork::Vocab::Person.from_json_ld(actor))
same_handle = ctx.get_actor_handle("https://example.com/users/alice")
bare = ctx.get_actor_handle(actor, Aptork::ActorHandleOptions.new(trim_leading_at: true))
normalized = Aptork.normalize_actor_handle("@Alice@EXAMPLE.COM")
unicode = Aptork.normalize_actor_handle("@quux@XN--MAANA-PTA.COM")
ascii = Aptork.normalize_actor_handle("@quux@MAÑANA.COM", Aptork::ActorHandleOptions.new(punycode: true))By default, lookup rejects objects whose id/@id origin differs from the
fetched URL, following the FEP-fe34 anti-spoofing shape. Set
cross_origin: "trust" to bypass the check or "throw" to turn mismatch into
an exception. The older Crystal-style "raise" spelling is kept as an alias:
ctx.lookup_object(
"https://forge.example/tickets/1",
Aptork::LookupObjectOptions.new(cross_origin: "trust")
)When an incoming activity embeds a cross-origin object, verify the object before trusting embedded fields. Aptork accepts same-origin embedded objects, dereferences cross-origin object ids, and rejects spoofed ids by default:
verified = ctx.verify_activity_object(activity)
verified = ctx.verify_activity_object(
activity,
Aptork::LookupObjectOptions.new(cross_origin: "throw")
)Collections can be traversed from an inline object or a URL. Aptork reads
orderedItems/items and follows first/next links:
offers = ctx.traverse_collection("https://market.example/offers", limit: 50)
offers.each do |offer|
puts offer["type"].as_s
end
best_effort = ctx.traverse_collection(
"https://market.example/offers",
Aptork::TraverseCollectionOptions.new(limit: 50, suppress_error: true)
)
with_loader = ctx.traverse_collection(
URI.parse("https://market.example/offers"),
Aptork::TraverseCollectionOptions.new(document_loader: loader)
)
typed = Aptork::Vocab::OrderedCollection.from_json_ld(collection_json)
typed_items = ctx.traverse_collection(typed)
typed_items.first.as(Aptork::Vocab::MarketplaceOffer).price_currencyTraverseCollectionOptions#suppress_error mirrors Fedify's suppressError
option for best-effort pagination when a later collection page or referenced
item cannot be fetched. document_loader overrides the context loader for a
single traversal, matching Fedify's per-call lookup options. When a collection
has a first page, traversal starts from that page instead of yielding any
top-level summary items, matching Fedify's traverseCollection.
Broader protocol conformance around HTTP Signature RFC 9421 and Linked Data Signatures/Object Integrity Proofs remains an area for ongoing parity work.
The library exposes generic builders for producing JSON-LD documents:
note = Aptork.note(
"https://example.com/notes/1",
"Hello from Crystal",
attributed_to: "https://example.com/users/alice"
)
activity = Aptork.create(
"https://example.com/activities/1",
"https://example.com/users/alice",
note
)Fedify-style activity builder helpers are available for the standard ActivityStreams activity types. Object-bearing activities accept either an embedded JSON-LD object or an IRI string:
follow = Aptork.follow(
"https://example.com/activities/follow/1",
"https://example.com/users/alice",
"https://remote.example/users/bob"
)
like = Aptork.like(
"https://example.com/activities/like/1",
"https://example.com/users/alice",
note
)
move = Aptork.move(
"https://example.com/activities/move/1",
"https://example.com/users/alice",
"https://example.com/users/alice",
target: "https://example.net/users/alice"
)
question = Aptork.question(
"https://example.com/questions/1",
"https://example.com/users/alice",
one_of: [
Aptork.note("https://example.com/questions/1/a", "A"),
"https://example.com/questions/1/b",
],
end_time: "2026-05-23T00:00:00Z"
)The helper set covers Accept, Add, Announce, Block, Create, Delete,
Dislike, Flag, Follow, Ignore, Invite, Join, Leave, Like,
Listen, Move, Offer, Reject, Read, Remove, TentativeAccept,
TentativeReject, Undo, Update, View, plus intransitive Arrive,
Travel, and Question.
Standard ActivityStreams object helpers mirror the typed parser surface and
cover common fields such as name, summary, content, mediaType, url,
attributedTo, attachment, tag, source text, and sensitivity flags:
image = Aptork.image(
"https://example.com/media/preview.png",
name: "Preview",
media_type: "image/png",
url: "https://cdn.example.com/media/preview.png"
)
article = Aptork.article(
"https://example.com/articles/1",
name: "Federated marketplace notes",
content: "Long-form content",
attributed_to: "https://example.com/users/alice",
attachments: [image],
source_content: "# Federated marketplace notes",
source_media_type: "text/markdown"
)The helper set covers Article, Audio, Document, Event, Image, Page,
Place, Profile, Relationship, and Video. Aptork.note and
Aptork.tombstone remain specialized helpers for the common Note and Tombstone
shapes.
Link, tag, custom emoji, and profile-field helpers are available for the objects commonly attached to posts and actor profiles:
preview = Aptork.link(
"https://cdn.example.com/media/preview.png",
rel: ["preview"],
media_type: "image/png",
name: "Preview"
)
mention = Aptork.mention(
"https://remote.example/users/bob",
"@bob@remote.example"
)
tag = Aptork.hashtag("#aptork", "https://example.com/tags/aptork")
field = Aptork.property_value("Website", "https://example.com")
emoji = Aptork.emoji(
"https://example.com/emoji/blobcat",
":blobcat:",
Aptork.image("https://example.com/emoji/blobcat.png", media_type: "image/png")
)Known type constants are available for app-level validation or UI:
Aptork::ACTOR_TYPESAptork::OBJECT_TYPESAptork::ACTIVITY_TYPESAptork::FORGEFED_TYPESAptork::MARKETPLACE_TYPES
Use Aptork.type_name(uri_or_name) and Aptork.type_id(name) to bridge compact
names and canonical JSON-LD type IDs. Vocab classes expose the same canonical
ID through type_id:
Aptork.type_name("https://www.w3.org/ns/activitystreams#Like") # => "Like"
Aptork::Vocab::Like.type_id
Aptork::Vocab::Repository.type_id
Aptork::Vocab::Multikey.type_idFedify-style typed parsing is available for common ActivityStreams objects:
parsed = Aptork::Vocab::Object.from_json_ld(activity)
if parsed.is_a?(Aptork::Vocab::Create)
parsed.actor
parsed.object
parsed.to_json_ld
end
announce = Aptork::Vocab::Announce.from_json_ld(announce_json)
announce.actor
announce.object
announce.target
announce.cc
follow = Aptork::Vocab::Follow.from_json_ld(follow_json)
follow.object
arrive = Aptork::Vocab::Arrive.from_json_ld(arrive_json)
arrive.is_a?(Aptork::Vocab::IntransitiveActivity)
note = Aptork::Vocab::Note.from_json_ld(note_json)
note.content
article = Aptork::Vocab::Article.from_json_ld(article_json)
article.name
article.summary
article.attachment
article.tags
article.previews
article.start_time
article.end_time
article.duration
article.source
article.proofs
article.sensitive
article.emoji_reactions
article.quote
article.quote_url
question = Aptork::Vocab::Question.from_json_ld(question_json)
question.one_of
question.any_of
question.end_time
question.voters_count
image = Aptork::Vocab::Image.from_json_ld(image_json)
image.media_type
image.url
link = Aptork::Vocab::Link.from_json_ld(link_json)
link.href
link.rel
hashtag = Aptork::Vocab::Hashtag.from_json_ld(hashtag_json)
hashtag.name
hashtag.href
mention = Aptork::Vocab::Mention.from_json_ld(mention_json)
mention.href
emoji = Aptork::Vocab::Emoji.from_json_ld(emoji_json)
emoji.name
emoji.icon
property_value = Aptork::Vocab::PropertyValue.from_json_ld(property_value_json)
property_value.name
property_value.value
collection = Aptork::Vocab::Collection.from_json_ld(collection_json)
collection.total_items
collection.items
actor = Aptork::Vocab::Actor.from_json_ld(actor_json)
actor.preferred_username
actor.manually_approves_followers
actor.liked
actor.featured
actor.featured_tags
actor.streams
actor.gateways
actor.discoverable
actor.indexable
actor.also_known_as
actor.successor
actor.endpoints.try(&.oauth_authorization_endpoint)
actor.endpoints.try(&.upload_media)
actor.shared_inbox
actor.public_key
actor.assertion_methods
person = Aptork::Vocab::Person.from_json_ld(person_json)
service_actor = Aptork::Vocab::Service.from_json_ld(service_actor_json)
key = Aptork::Vocab::CryptographicKey.from_json_ld(public_key_json)
key.public_key_pem
multikey = Aptork::Vocab::Multikey.from_json_ld(multikey_json)
multikey.public_key_multibase
tombstone = Aptork::Vocab::Tombstone.from_json_ld(tombstone_json)
tombstone.former_type
tombstone.deleted
repo = Aptork::Vocab::Repository.from_json_ld(repository_json)
repo.clone_uri
repo.push_uris
project = Aptork::Vocab::Project.from_json_ld(project_json)
project.inbox
branch = Aptork::Vocab::Branch.from_json_ld(branch_json)
branch.ref
tag = Aptork::Vocab::ForgeFedTag.from_json_ld(tag_json)
tag.href
commit = Aptork::Vocab::Commit.from_json_ld(commit_json)
commit.hash
tracker = Aptork::Vocab::TicketTracker.from_json_ld(ticket_tracker_json)
tracker.outbox
push = Aptork::Vocab::Push.from_json_ld(push_json)
push.hash_after
push.commits
ticket = Aptork::Vocab::Ticket.from_json_ld(ticket_json)
ticket.resolved
offer = Aptork::Vocab::MarketplaceOffer.from_json_ld(offer_json)
offer.item
offer.price_currency
listing = Aptork::Vocab::Listing.from_json_ld(listing_json)
listing.item
listing.price_specification
proposal = Aptork::Vocab::Proposal.from_json_ld(proposal_json)
proposal.publishes
proposal.reciprocal
proposal.publishes.as(Aptork::Vocab::Intent).resource_quantity.try(&.unit)
agreement = Aptork::Vocab::Agreement.from_json_ld(agreement_json)
agreement.stipulatesObject.from_json_ld dispatches known subtypes such as common ActivityStreams
activities (Accept, Announce, Follow, Like, Undo, Update, and the
other ACTIVITY_TYPES entries, including IntransitiveActivity and its
Arrive/Question/Travel subtypes), standard ActivityStreams objects (Article,
Audio, Document, Event, Image, Note, Page, Place, Profile,
Relationship, Video), Link plus link subtypes Mention/Hashtag,
Mastodon-style Emoji, schema.org PropertyValue,
Collection/OrderedCollection/collection pages, Tombstone, all
ActivityStreams actor subtypes (Application, Group, Organization,
Person, Service) with typed Endpoints,
ForgeFed Repository/Branch/Commit/Push/Ticket, and marketplace
Offer/Product/PriceSpecification/Listing plus ValueFlows
Intent/Measure/Proposal/Commitment/Agreement, while unknown vocabulary
types are preserved as base objects with their raw json map intact.
Context#send_activity delivers immediately and records successful deliveries
in Federation#sent_activities:
transport = Aptork::Transport.new(signature_enabled: false)
federation = Aptork::Federation.create("https://example.com", transport)
ctx = federation.create_context
recipient = Aptork::Recipient.new(
"https://remote.example/users/bob",
"https://remote.example/inbox"
)
ctx.send_activity("alice", [recipient], activity)Explicit recipients can be passed as one recipient, an array of recipients, a
raw ActivityPub actor document, a typed Aptork::Vocab::Actor, or arrays of
either actor shape. Use
Aptork::SendActivityOptions with explicit recipients for Fedify-style shared
inbox selection, same-origin exclusion, queueing, ordering keys, and fanout:
remote_actor_json = fetch_remote_actor(...)
result = ctx.send_activity(
"alice",
remote_actor_json,
activity,
Aptork::SendActivityOptions.new(
prefer_shared_inbox: true,
exclude_base_uris: ["https://example.com"],
ordering_key: "alice"
)
)
result.sent
result.queued
result.fanout_queuedWhen a remote actor document is already available, Aptork.recipient_from_actor
converts its id/@id, inbox, and endpoints.sharedInbox fields into an
explicit recipient without requiring a typed vocabulary wrapper:
recipient = Aptork.recipient_from_actor(
remote_actor_json,
prefer_shared_inbox: true
)The sender can also be passed in the Fedify-style identity shape. Use
{identifier: "alice"} when the internal actor identifier is already known, or
{username: "alice"} to resolve a public handle through map_handle first:
federation.map_handle(->(_ctx : Aptork::Context, username : String) do
username == "alice" ? "user-123" : nil
end)
ctx.send_activity({username: "alice"}, [recipient], activity)
ctx.send_activity({identifier: "user-123"}, [recipient], activity)Identity senders accept the same explicit recipient shapes as string senders:
Recipient, raw ActivityPub actor documents, typed Aptork::Vocab::Actor
values, and actor arrays.
Like Fedify, Aptork rejects outgoing sends whose transformed activity has no
id/@id or actor, whether the delivery is immediate or queued. Install
auto_id_assigner or add_default_activity_transformers when you want Aptork
to assign local ids before validation.
Like Fedify's sender key-pair form, explicit RSA/Ed25519 key pairs can also be
used when the activity already carries its actor; Aptork rejects key-pair
sends whose transformed activity has no actor or whose explicit key pairs lack
private key material. Explicit sender keys must use rsa-sha256 or ed25519;
Ed25519 senders need private key PEM because Aptork creates Ed25519 Object
Integrity Proofs from PEM material:
key_pair = Aptork::ActorKeyPair.new(
"https://example.com/users/alice#main-key",
"https://example.com/users/alice",
public_key_pem,
private_key_pem: private_key_pem
)
ctx.send_activity(
key_pair,
remote_actor_json,
activity,
Aptork::SendActivityOptions.new(ordering_key: "alice")
)When an outbox queue is configured, Aptork serializes the explicit sender key
pairs into each queued delivery task so workers can sign the later HTTP POST.
As in Fedify, "followers" delivery still requires an identifier or username
sender because key-pair senders do not imply a local followers collection.
Key-pair senders can use explicit Recipient values, raw ActivityPub actor
documents, typed Aptork::Vocab::Actor values, or arrays of either actor shape.
When followers are exposed through set_followers_dispatcher, activities can be
sent to the followers collection directly:
result = ctx.send_activity(
{username: "alice"},
"followers",
activity,
Aptork::SendActivityOptions.new(
prefer_shared_inbox: true,
sync_collection: true,
exclude_base_uris: ["https://example.com"]
)
)
result.sent
result.queued
result.fanout_queuedFollower actors are converted to recipients from id and inbox. When
prefer_shared_inbox is enabled, endpoints.sharedInbox is used and duplicate
shared inboxes are collapsed. exclude_base_uris skips local actors or inboxes.
When sync_collection is enabled for "followers" delivery, Aptork adds a
FEP-8fcf Collection-Synchronization header with the sender's followers
collection URI and the partial follower digest for the actor IDs represented by
that inbox. Followers collection GET also supports ?base-url= filtering for
remote servers that need to synchronize their slice of the collection.
For Fedify-style delivery planning without sending, call Aptork.extract_inboxes
with explicit recipients. It returns a hash keyed by inbox URL and records the
actor IDs represented by each inbox:
recipients = [
Aptork::Recipient.new(
"https://remote.example/users/bob",
"https://remote.example/users/bob/inbox",
shared_inbox: "https://remote.example/inbox"
),
Aptork::Recipient.new(
"https://remote.example/users/carol",
"https://remote.example/users/carol/inbox",
shared_inbox: "https://remote.example/inbox"
),
]
inboxes = Aptork.extract_inboxes(
recipients,
prefer_shared_inbox: true,
exclude_base_uris: ["https://example.com"]
)
inboxes["https://remote.example/inbox"].actor_ids
inboxes["https://remote.example/inbox"].shared_inboxexclude_base_uris follows Fedify's origin-level exclusion behavior here, so a
base such as https://remote.example/projects/1 excludes recipients on the
https://remote.example origin.
For Fedify-style queued delivery, configure an outbox queue and call
Context#enqueue_activity. Each recipient becomes one queued outbound delivery
message. When no ordering key is specified, Aptork uses the queue's
enqueue_many hook so backends can batch multi-recipient sends in the same
style as Fedify's optional MessageQueue.enqueueMany() API. When an ordering
key is specified, Aptork keeps individual enqueues so queue implementations can
preserve per-message ordering. Context#process_queued_activities consumes the
in-process queue, posts through the configured transport, retries failures
according to the retry policy, and records successful sends.
For large follower deliveries, configure a fan-out queue. Collection sends use
Fedify-style fanout: "auto" | "skip" | "force" options. "auto" enqueues one
FanoutDelivery task when the recipient count reaches the configured threshold;
"skip" always queues per recipient; "force" always uses the fan-out queue
when one is configured. Any other value raises an ArgumentError instead of
silently falling back to automatic behavior. The fan-out worker expands that
task into normal outbound delivery messages. Explicit sender key pairs are
serialized with the fan-out task, so forced or threshold-based fanout still
signs the expanded outbound deliveries with the provided sender identity:
outbox_queue = Aptork::InProcessMessageQueue.new
fanout_queue = Aptork::InProcessMessageQueue.new
federation = Aptork::Federation.create(
"https://example.com",
transport,
outbox_queue: outbox_queue
).configure_fanout_queue(fanout_queue, threshold: 100)
result = ctx.send_activity(
"alice",
"followers",
activity,
Aptork::SendActivityOptions.new(fanout: "auto")
)
ctx.process_queued_fanout_activities(limit: 10)
ctx.process_queued_activities(limit: 10)Context#get_document_loader accepts the same sender identity tuples for signed
fetch. Passing an explicit key pair matches Fedify's key-pair form and requires
an RSA-SHA256 key with private key material:
loader = ctx.get_document_loader({username: "alice"})
loader = ctx.get_document_loader(rsa_key_pair)If a username cannot be mapped for document loading, Aptork falls back to the
public federation document loader. Sending with an unmapped username raises an
ArgumentError.
queue = Aptork::InProcessMessageQueue.new
policy = Aptork::RetryPolicy.new(max_attempts: 5)
federation = Aptork::Federation.create(
"https://example.com",
transport,
outbox_queue: queue,
outbox_retry_policy: policy
)
ctx = federation.create_context
ctx.enqueue_activity(
"alice",
[recipient],
activity,
Aptork::EnqueueOptions.new(ordering_key: "alice")
)
ctx.process_queued_activities(limit: 10)Queued outbound deliveries that fail with permanent inbox statuses skip retries.
By default, 404 and 410 are treated as permanent failures. Override the set
when a deployment treats additional statuses, such as 451, as terminal:
federation = Aptork::Federation.create(
"https://example.com",
permanent_failure_status_codes: [404, 410, 451]
)
federation.set_permanent_failure_status_codes([404, 410, 451])Register a handler to remove stale followers or update local delivery state:
federation.set_outbox_permanent_failure_handler(
->(_ctx : Aptork::Context, failure : Aptork::OutboxPermanentFailure) do
remove_followers_for_inbox(failure.inbox, failure.actor_ids)
nil
end
)For transient delivery failures, register an outbox error handler. It is called for each failed queued attempt that will continue through the retry policy:
federation.set_outbox_error_handler(
->(_ctx : Aptork::Context, failure : Aptork::OutboxDeliveryFailure) do
log_delivery_retry(failure.inbox, failure.status_code, failure.attempts)
nil
end
)For errors raised by local outbox listeners themselves, use the Fedify-style listener-scoped handler:
federation.set_outbox_listeners("/users/{identifier}/outbox")
.on("Create", ->(_ctx, _activity) do
raise "could not persist local side effect"
end)
.on_error(->(ctx : Aptork::Context, error : Exception) do
log_outbox_listener_error(ctx.outbox_identifier, error)
nil
end)You can also attach queueing after creation:
federation.configure_outbox_queue(queue, retry_policy: policy)Activity transformers mirror Fedify's outgoing compatibility hook. They run once for each outbound activity batch, before Object Integrity Proofs and before immediate delivery or queue serialization:
federation.add_activity_transformer(
->(_ctx : Aptork::Context, transform : Aptork::ActivityTransformContext, activity : Aptork::JsonMap) do
activity["audience"] = Aptork.json(transform.recipients.map(&.id))
activity
end
)The transformer receives a deep JSON copy of the outbound activity, so callers' original activity maps are not mutated. Forwarded inbox activities are not transformed; Aptork preserves the original forwarded body.
Aptork includes Fedify-style default compatibility transformers for outbound activities:
federation.add_default_activity_transformers
# Or install them individually.
federation.add_activity_transformer(Aptork::Federation.auto_id_assigner)
federation.add_activity_transformer(Aptork::Federation.actor_dehydrator)
federation.add_activity_transformer(Aptork::Federation.public_audience_normalizer)
federation.add_activity_transformer(Aptork::Federation.attachment_array_normalizer)auto_id_assigner assigns a local fragment ID to outgoing activities that do
not already have id or @id, using the shape
https://example.com/#Create/<random>. Send validation still runs after
transformers, so outgoing activities must have both id and actor after
transformation. actor_dehydrator replaces inline actor
objects in the top-level actor property with their actor URI, including
arrays of actor values. public_audience_normalizer rewrites Public and
as:Public in audience fields to the ActivityStreams public collection URI.
attachment_array_normalizer wraps scalar attachment values as one-item
arrays. These normalizers run before Aptork creates outbound Object Integrity
Proofs, so the delivered wire shape is what gets signed.
Federation#sent_activities tracks delivered metadata. This is useful for
tests and mirrors the testing-oriented shape in @fedify/testing: each record
includes sender_identifier, recipient, activity_id, activity, queued,
queue, optional raw_activity for preserved forwarded payloads, and
monotonic sent_order metadata. Use reset or
reset_sent_activities between test phases, similar to Fedify's mock federation
reset helper.
Aptork::Testing also exposes small helpers for testing outbox code without a
web framework. receive_activity mirrors Fedify's mock receiveActivity
helper, while the POST helpers accept context_data: so listener tests can
exercise tenant or request-scoped data:
federation = Aptork::Testing.create_federation("https://example.com")
rsa_key = Aptork::Testing.generate_rsa_key_pair(
"https://example.com/users/alice"
)
ed25519_key = Aptork::Testing.generate_ed25519_key_pair(
"https://example.com/users/alice",
"https://example.com/users/alice#multikey-1"
)
request_ctx = Aptork::Testing.create_request_context(
federation,
Aptork::Request.new("GET", "/users/alice")
)
mocked_ctx = Aptork::Testing.create_context(
federation,
Aptork::Request.new("GET", "/users/alice"),
recipient_identifier: "alice",
context_data: Aptork.json({"tenant" => "test"}),
document_loader: ->(url : String) {
Aptork::JsonMap{"id" => Aptork.json(url)}.as(Aptork::JsonMap?)
}
)
inbox_ctx = Aptork::Testing.create_inbox_context(federation, "alice")
inbox_ctx.identifier # => "alice"
inbox_ctx.recipient_identifier # => "alice"
ctx = Aptork::Testing.create_outbox_context(federation, "alice")
ctx.identifier # => "alice"
ctx.outbox_identifier # => "alice"
ctx.has_delivered_activity? # => false
inbox_response = Aptork::Testing.receive_activity(
federation,
Aptork.create(
"https://remote.example/activities/1",
"https://remote.example/users/bob",
Aptork.note("https://remote.example/notes/1", "Hello")
),
"alice",
context_data: Aptork.json({"tenant" => "test"})
)
response = Aptork::Testing.post_outbox_activity(
federation,
"alice",
Aptork.create(
"https://example.com/activities/1",
"https://example.com/users/alice",
Aptork.note("https://example.com/notes/1", "Hello")
),
context_data: Aptork.json({"tenant" => "test"})
)Inside an outbox listener, ctx.has_delivered_activity? starts as false for
the routed activity and becomes true after ctx.send_activity or
ctx.forward_activity successfully sends or queues work.
If an outbox listener returns without delivering or forwarding the posted
activity, Aptork emits a warning on aptork.federation.outbox. Applications can
also observe that condition directly:
federation.on_undelivered_outbox_activity(->(ctx : Aptork::Context, activity : Aptork::JsonMap) do
log_unfederated_outbox_post(ctx.outbox_identifier, activity["id"]?)
nil
end)Aptork::Transport is the HTTP delivery layer. It posts ActivityPub JSON-LD to
recipient inboxes and can add legacy RSA HTTP Signature headers:
transport = Aptork::Transport.new(
signature_enabled: true,
signature_key_path: "/etc/keys/private.pem",
signature_key_id: "https://example.com/users/alice#main-key"
)Testing hooks:
post_provider: replaces network delivery.detailed_post_provider: replaces network delivery and can return response headers such asAccept-Signature.headers_provider: replaces signature header generation.
Transport still emits the older (request-target) host date digest RSA style by
default for fediverse compatibility. Actor key-pair dispatchers are preferred
for application code; signature_key_path/signature_key_id are still supported
as a static fallback. If a recipient responds with 401 and an
Accept-Signature challenge, delivery retries once with an RFC 9421 signature
that follows the first compatible challenge's requested components, nonce, tag,
and expires flag when a signing key is available. Challenge negotiation accepts
RSA SHA-256 or unspecified algorithms, skips challenges for a different key id,
and refuses request-invalid response components such as @status. Use
Aptork::Signatures.attach_object_proof before delivery when the activity itself
should carry an embedded DataIntegrityProof.
Fedify includes OpenTelemetry instrumentation. Aptork keeps the core shard dependency-free and exposes a small telemetry adapter surface that can be bridged to OpenTelemetry, logs, or tests:
class AppTelemetry < Aptork::Telemetry
def span(name : String, attributes : Aptork::TelemetryAttributes = Aptork::TelemetryAttributes.new, &block)
start_span(name, attributes)
yield
ensure
finish_span(name)
end
def counter(name : String, value : Int64 = 1_i64, attributes : Aptork::TelemetryAttributes = Aptork::TelemetryAttributes.new) : Nil
record_counter(name, value, attributes)
end
def histogram(name : String, value : Float64, attributes : Aptork::TelemetryAttributes = Aptork::TelemetryAttributes.new) : Nil
record_histogram(name, value, attributes)
end
end
federation = Aptork::Federation.create(
"https://example.com",
telemetry: AppTelemetry.new
)The no-op default has no runtime dependency. Built-in instrumentation currently records HTTP request spans and request counters/durations, inbox and outbox routing spans/counters, and outbound delivery spans/counters.
For feature parity with Fedify's OpenTelemetry metrics without taking on an
external dependency, Aptork ships Aptork::MetricsTelemetry. It aggregates
counters, gauges, histograms, and span timings in memory (thread-safe behind a
Mutex) and renders them in the OpenMetrics / Prometheus text exposition format,
ready to serve from a /metrics endpoint that a Prometheus-compatible scraper
(or any OpenTelemetry collector with a Prometheus receiver) can read:
metrics = Aptork::MetricsTelemetry.new
federation = Aptork::Federation.create(
"https://example.com",
telemetry: metrics
)
# span/counter/histogram/gauge are recorded automatically by the framework, or
# manually from application code:
metrics.counter("app.activities.created", attributes: Aptork::TelemetryAttributes{"type" => "Note"})
metrics.gauge("app.queue.depth", 12.0)
metrics.span("app.deliver") { deliver_activity }
# Serve from an HTTP handler.
exposition = metrics.to_openmetrics # alias: to_prometheusspan records a <name>_duration_seconds histogram (plus a call counter) using
a monotonic clock, so request, routing, and delivery latencies are exported with
default buckets. Introspection helpers (counter_value, gauge_value,
histogram_data) and reset make MetricsTelemetry convenient in tests too.
Fedify exposes pluggable stores and queues. Aptork includes small in-memory versions for local apps and tests, plus Redis-backed adapters for durable idempotency, cache, and queue state:
store = Aptork::MemoryKvStore.new
store.set("actor:alice", "...")
store.list("actor:")
store.cas("lock:actor", nil, "alice")
queue = Aptork::InProcessMessageQueue.new
queue.enqueue(
"outbox",
activity,
Aptork::EnqueueOptions.new(
delay: Time::Span.new(seconds: 5),
ordering_key: "alice"
)
)
policy = Aptork::RetryPolicy.new(max_attempts: 3)
queue.listen("outbox", policy, limit: 10) do |message|
# deliver message.payload
endredis = Aptork::RedisProtocolClient.from_url("redis://localhost:6379/0")
store = Aptork::RedisKvStore.new(redis, prefix: "my-app")
queue = Aptork::RedisMessageQueue.new(redis, prefix: "my-app")
federation = Aptork::Federation.create(
"https://example.com",
kv: store,
inbox_queue: queue,
outbox_queue: queue
)KvStore#list(prefix) mirrors Fedify's required KvStore.list() capability
for enumerating related entries. MemoryKvStore#cas mirrors Fedify's optional
KvStore.cas() compare-and-swap operation for optimistic local coordination.
RedisKvStore supports list and atomic cas, using Redis EVAL for
compare-and-swap plus GET, SET PX, DEL, and prefix scans for list.
RedisMessageQueue stores scheduled messages in sorted sets and exposes the
same enqueue, ready, enqueue_many, depth, get_depth, process_one,
and listen helpers as the in-process queue.
Context#process_queued_activities, #process_queued_inbox_activities, and
#process_queued_fanout_activities consume any configured queue that implements
MessageQueue#listen, so Redis-backed workers can use the same processing
helpers as tests that use InProcessMessageQueue.
For durable, transactional persistence without an external broker, Aptork
provides SQL-backed implementations of both the KvStore and MessageQueue
interfaces. SqlKvStore and SqlMessageQueue work over any object that includes
the Aptork::SqlConnection module; the bundled connections target SQLite and
PostgreSQL through Crystal's DB ecosystem, and the connection supplies the
right placeholder/upsert dialect by default. Both stores migrate their tables
on construction by default.
PostgreSQL is supported by Aptork::PostgresConnection, a thin adapter over
will/crystal-pg. Pull it in explicitly when you need PostgreSQL-backed
storage:
require "aptork"
require "aptork/store/postgres"
conn = Aptork::PostgresConnection.connect("postgres://user:pass@localhost:5432/aptork")
store = Aptork::SqlKvStore.new(conn)
queue = Aptork::SqlMessageQueue.new(conn)
federation = Aptork::Federation.create(
"https://example.com",
kv: store,
inbox_queue: queue,
outbox_queue: queue
)SQLite is supported by Aptork::SqliteConnection, a thin adapter over
crystal-lang/crystal-sqlite3. Because it links SQLite at compile time, it is
also opt-in:
require "aptork"
require "aptork/store/sqlite"
conn = Aptork::SqliteConnection.open("aptork.db") # or ":memory:"
store = Aptork::SqlKvStore.new(conn)
queue = Aptork::SqlMessageQueue.new(conn)SqlKvStore supports get/set with TTL expiry, delete, prefix list, and
atomic cas. SqlMessageQueue supports enqueue/enqueue_many with delay and
ordering keys, depth/get_depth, FIFO process_one with retry/backoff and a
dead-letter table, and listen, matching the in-process and Redis queues. You
can also point the SQL stores at any other engine by implementing
Aptork::SqlConnection#execute and #query over your own driver.
For Fedify-style queue observability, MessageQueue#get_depth returns
structured ready/delayed counts when the backend supports it. Custom backends can
leave it unimplemented and return nil from the default method:
depth = queue.get_depth("outbox")
depth.try(&.queued)
depth.try(&.ready)
depth.try(&.delayed)By default, Aptork starts lightweight Crystal fiber workers when queued inbox, outbox, or fanout work is enqueued. This mirrors Fedify's default queue lifecycle while keeping the shard framework-neutral. For a split web/worker deployment, disable automatic startup in the web process and start the worker process explicitly:
federation = Aptork::Federation.create(
"https://example.com",
inbox_queue: queue,
outbox_queue: queue,
fanout_queue: queue,
manually_start_queue: true
)
worker = federation.start_queue(
options: Aptork::QueueStartOptions.new(
queues: ["inbox", "outbox", "fanout"],
poll_interval: Time::Span.new(seconds: 1),
limit: 25
)
)
Signal::INT.trap do
worker.stop
exit
endFederation#start_queue is idempotent for queue roles that are already running,
and calling it again with a different queues subset adds the missing workers.
#queue_started? reports whether a worker is active, and #stop_queue stops
the current worker. QueueStartOptions can limit which queue roles run in a
process, which is useful when inbox, outbox, and fanout workers are scaled
separately.
External backends can still implement MessageQueue#enqueue for producer-side
integration and provide their own workers that deserialize OutboundDelivery
and InboundDelivery payloads. For Fedify-style worker integrations, pass the
queued task payload to Federation#process_queued_task or
Context#process_queued_task:
federation.process_queued_task(message.payload, context_data: worker_data)
ctx = federation.create_context(context_data: worker_data)
ctx.process_queued_task(message.payload)OutboundDelivery tasks deliver the activity and record sent metadata.
InboundDelivery tasks route to inbox listeners immediately and raise if no
listener handles the activity, so external queue backends can apply their own
retry policy. Inbound tasks produced by Aptork include trusted provenance after
HTTP verification or manual dereferencing. Custom producers can opt in with:
payload = federation.inbound_delivery_payload("alice", verified_activity, trusted: true)Discovery helpers:
jrd = Aptork.webfinger_jrd(
"acct:alice@example.com",
"https://example.com/users/alice"
)
nodeinfo = Aptork.nodeinfo(
"aptork-app",
"0.1.0",
software_repository: "https://github.com/example/aptork-app",
software_homepage: "https://example.com",
inbound_services: ["rss2.0"],
outbound_services: ["wordpress"],
users_total: 42,
users_active_month: 7,
local_posts: 100,
local_comments: 5
)The default WebFinger self link uses Fedify's application/activity+json
media type.
NodeInfo client lookup follows /.well-known/nodeinfo, uses the first
recognized NodeInfo 2.0 or 2.1 link in the JRD as Fedify does, and then fetches
the linked document. Default NodeInfo lookups send Fedify's
Accept: application/json header.
Aptork's NodeInfo document responses and well-known links use Fedify's NodeInfo
2.1 profile media type,
application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#".
As in Fedify, the NodeInfo document route is advertised only after a NodeInfo
dispatcher is configured; otherwise /.well-known/nodeinfo returns an empty
JRD links array and ctx.get_nodeinfo_uri raises.
nodeinfo = ctx.lookup_nodeinfo("https://remote.example")
same_nodeinfo = ctx.lookup_nodeinfo(URI.parse("https://remote.example/users/alice"))Like Fedify's getNodeInfo(), non-direct lookups resolve
/.well-known/nodeinfo from the origin root even when the input URI has a path.
Use best-effort for slightly invalid remote documents, none to return raw
JSON without validation, or direct: true when you already have the NodeInfo
document URL:
raw = ctx.lookup_nodeinfo(
"https://remote.example/nodeinfo/2.1",
Aptork::NodeInfoLookupOptions.new(direct: true, parse: "none")
)Use lookup_nodeinfo_document when you want the Fedify-like typed NodeInfo
client surface instead of raw JSON. Strict parsing rejects invalid software
names, unsupported protocols/services, malformed optional services,
openRegistrations, usage, or metadata fields, and negative usage
counters. As in Fedify, a services object may include only inbound, only
outbound, or neither field. Missing top-level version or usage fields are
normalized to NodeInfo 2.1 defaults, and unsupported top-level version values
do not reject the document; when usage is present it must include a users
object, while missing localPosts or localComments counters default to zero.
Non-string software versions are stringified while parsing; parse: "best-effort" lowercases and trims otherwise valid software names, but still
rejects names with unsupported characters, matching Fedify's parser. Invalid
software repository and homepage values are ignored in best-effort mode,
and usage counters are parsed with Fedify-like integer-prefix handling. Negative
integer counters can be parsed from remote documents, but serialization rejects
them.
Aptork.nodeinfo_to_json serializes typed values back to NodeInfo 2.1 JSON with
the same version and schema marker Fedify emits, even when the typed value came
from a NodeInfo 2.0 fallback document.
nodeinfo = ctx.lookup_nodeinfo_document("https://remote.example")
if nodeinfo
nodeinfo.software.name
nodeinfo.usage.users.total
end
semver = Aptork.parse_semver(nodeinfo.software.version) if nodeinfo
json = Aptork.nodeinfo_to_json(nodeinfo) if nodeinfoApplications can override discovery responses in the Fedify style:
federation.map_handle(->(_ctx : Aptork::Context, username : String) do
lookup_user_id_by_handle(username)
end)
federation.map_alias(->(_ctx : Aptork::Context, resource : String) do
lookup_user_id_by_profile_url(resource)
end)
federation.map_alias(->(_ctx : Aptork::Context, resource : String) do
# Fedify-style aliases may return either an internal identifier directly
# or a username that is resolved through `map_handle`.
resource == "https://example.com/@alice" ? {username: "alice"} : nil
end)
federation.set_webfinger_links_dispatcher(->(_ctx : Aptork::Context, resource : String) do
[
Aptork::JsonMap{
"rel" => Aptork.json("http://ostatus.org/schema/1.0/subscribe"),
"template" => Aptork.json("https://example.com/authorize_interaction?uri={uri}"),
},
]
end)
federation.set_webfinger_dispatcher(->(_ctx : Aptork::Context, resource : String, identifier : String) do
Aptork.webfinger_jrd(
resource,
"https://example.com/users/#{identifier}",
properties: Aptork::JsonMap{
"https://example.com/ns#role" => Aptork.json("maintainer"),
}
).as(Aptork::JsonMap?)
end)
federation.set_nodeinfo_dispatcher("/nodeinfo/custom", ->(_ctx : Aptork::Context) do
Aptork.nodeinfo(
"my-app",
"1.0.0",
metadata: Aptork::JsonMap{"nodeName" => Aptork.json("My node")}
)
end)WebFinger accepts URI-form resources, returns Fedify-style plain-text 400
responses for missing, whitespace-bearing, or malformed HTTP(S) resource
parameters,
accepts acct: resources for the configured handle host and canonical origin
authority, including a port when one is configured, and resolves local actor URL
resources through parse_uri before falling back to alias mapping for other URI
schemes. Like Fedify, URI-form resources remain the JRD subject; acct:
resource hosts are normalized with lowercasing and Punycode before host
matching and before being emitted as the JRD subject. Account handles are
exposed through aliases alongside the actor self link. Plain actor url
values become profile-page links without a media type, while structured link
objects can provide mediaType or type, matching Fedify's WebFinger output.
ForgeFed repository and push objects can be built directly:
repo = Aptork.forgefed_repository(
"https://example.com/repos/aptork",
"aptork",
"https://example.com/repos/aptork/inbox",
"https://example.com/repos/aptork/outbox",
clone_uri: "https://example.com/repos/aptork.git",
push_uris: ["ssh://git@example.com/aptork.git"],
tickets_tracked_by: "https://example.com/repos/aptork",
send_patches_to: "https://example.com/repos/aptork"
)
branch = Aptork.forgefed_branch(
"https://example.com/repos/aptork/branches/main",
"https://example.com/repos/aptork",
"main",
"refs/heads/main"
)
commit = Aptork.forgefed_commit(
"https://example.com/repos/aptork/commits/be9f48",
"https://example.com/repos/aptork",
"be9f48",
"https://example.com/users/alice",
"Add signed delivery",
files_added: ["src/federation.cr"],
files_modified: ["README.md"]
)
push = Aptork.forgefed_push(
"https://example.com/repos/aptork/outbox/push-1",
"https://example.com/repos/aptork",
"https://example.com/users/alice",
branch["id"].as_s,
[commit],
"017cbb",
"be9f48"
)
project = Aptork.forgefed_project(
"https://example.com/projects/aptork",
"Aptork",
inbox: "https://example.com/projects/aptork/inbox",
outbox: "https://example.com/projects/aptork/outbox"
)
tag = Aptork.forgefed_tag(
"https://example.com/repos/aptork/tags/v1.0.0",
"v1.0.0",
context: repo["id"].as_s
)
tickets = Aptork.forgefed_ticket_tracker(
"https://example.com/repos/aptork/tickets",
"Tickets",
context: repo["id"].as_s
)
patches = Aptork.forgefed_patch_tracker(
"https://example.com/repos/aptork/patches",
"Patches",
context: repo["id"].as_s
)ForgeFed task handoff can be built with forgefed_ticket:
dependency = Aptork.forgefed_ticket_dependency(
"https://example.com/ticket-deps/1",
"https://example.com/tickets/1",
"https://example.com/tickets/0",
attributed_to: "https://example.com/users/alice",
summary: "Delivery bug depends on the tracked regression"
)
ticket = Aptork.forgefed_ticket(
"https://example.com/tickets/1",
"Fix federation delivery",
"Inbox delivery fails on 410 responses",
assignee: "https://remote.example/users/maintainer",
attributed_to: "https://example.com/users/alice",
depends_on: [dependency]
)
activity = Aptork.create(
"https://example.com/activities/create-ticket-1",
"https://example.com/users/alice",
ticket
)Merge requests are represented in the current ForgeFed draft as ticket-like objects with patch metadata, so Aptork exposes a builder for that JSON-LD shape:
mr = Aptork.forgefed_merge_request(
"https://example.com/mrs/1",
"Add ActivityPub support",
"Implements signed inbox delivery",
"https://example.com/repos/aptork",
"https://remote.example/repos/app/branches/activitypub",
"https://example.com/repos/aptork/branches/main",
mr_diff: "https://example.com/mrs/1.diff"
)ForgeFed-specific interaction activities add the ForgeFed context and parse back to typed vocabulary classes:
resolved = Aptork.forgefed_resolve(
"https://example.com/activities/resolve-1",
"https://example.com/users/alice",
ticket,
target: repo["id"].as_s
)
applied = Aptork.forgefed_apply(
"https://example.com/activities/apply-1",
"https://example.com/users/alice",
"https://example.com/patches/1",
target: branch["id"].as_s
)Use the optional strict validators when receiving ForgeFed documents from remote servers:
return unless Aptork.valid_forgefed?(resolved)
Aptork.validate_forgefed!(commit)Marketplace-style offers can be represented with marketplace_offer:
service = Aptork.object("Service", "https://example.com/services/solver", Aptork::JsonMap{
"name" => Aptork.json("Solver access"),
})
offer = Aptork.marketplace_offer(
"https://example.com/offers/1",
"https://example.com/users/alice",
service,
"Solver access",
price: "10",
currency: "USD"
)Product listings can use the lighter marketplace vocabulary helpers:
product = Aptork.marketplace_product(
"https://example.com/products/solver",
"Solver access",
summary: "Priority solver access"
)
service = Aptork.marketplace_service(
"https://example.com/services/review",
"Review service",
summary: "Code review",
provider: "https://example.com/users/alice",
terms_of_service: "https://example.com/terms"
)
price = Aptork.marketplace_price_specification("10", "USD", unit_text: "month")
listing = Aptork.marketplace_listing(
"https://example.com/listings/solver",
"https://example.com/users/alice",
service,
"Solver subscription",
price
)FEP-0837 proposal and agreement flows can be composed with Valueflows-style intent and commitment builders:
primary = Aptork.marketplace_intent(
"https://market.example/proposals/1#primary",
"transfer",
Aptork.marketplace_quantity(value: "1")
)
proposal = Aptork.marketplace_proposal(
"https://market.example/proposals/1",
"offer",
"https://market.example/users/alice",
primary,
name: "Used bike"
)
agreement = Aptork.marketplace_agreement(
Aptork.marketplace_commitment(
"https://market.example/proposals/1#primary",
Aptork.marketplace_quantity(value: "1")
)
)
offer = Aptork.marketplace_agreement_offer(
"https://social.example/offers/1",
"https://social.example/users/bob",
agreement,
["https://market.example/users/alice"]
)FEP-0837 documents can also be checked explicitly before application code accepts them:
Aptork.valid_fep_0837?(proposal)
Aptork.validate_fep_0837!(agreement)These helpers build JSON-LD vocabulary objects. Routing, persistence, negotiation state machines, payment execution, and transaction settlement remain application concerns.
Implemented now:
Federationregistry andContextURI helpers,- Crystal-style
Aptork.federationsetup DSL, - Fedify-style
FederationOriginhandle/web origin object, - Fedify-style HTTP(S) origin-root validation,
- optional trailing-slash-insensitive route matching,
- actor/object/outbox dispatchers,
- followers/following/inbox/liked/featured/featured-tags collection dispatchers,
- unordered and ordered custom collection dispatchers,
- Fedify-style collection item filters,
- typed inbox listener routing,
- typed outbox listener routing for client-to-server POSTs,
- framework-neutral request/response routing,
- Fedify-style KV listing for memory and Redis stores,
- Fedify-style in-memory KV compare-and-swap,
- Fedify-style WebFinger URI and
mailto:lookup resources, - Fedify-style per-call lookup document loaders,
- Fedify-style URL/Crystal URI object lookup identifiers,
- Fedify-style
cross_origin: "throw"lookup strict mode, - outbound
send_activity, - outbound activity transformers for compatibility adjustments,
- followers-recipient expansion for
send_activity, - Fedify-style fan-out queue tasks for large collection delivery,
- queued outbound delivery with retries for in-process and Redis queues,
- Fedify-style batch queue enqueueing with
MessageQueue#enqueue_many, - Fedify-style structured queue depth reporting,
- configurable permanent delivery failure status codes,
- queued inbound inbox processing with retries for in-process queues,
- inbox forwarding with original-body delivery,
- actor key-pair dispatchers,
- automatic actor
publicKeyenrichment, - dynamic RSA HTTP signing for immediate and queued delivery,
- Fedify-style
{identifier: ...}and{username: ...}sender identities, - automatic Ed25519 Object Integrity Proof signing before recipient delivery,
- resolver-backed inbox RSA signature verification with actor/key-owner checks,
- signed-fetch access control for actor/object/collection routes,
- remote object lookup by URL, handle, or
acct:URI, - KV-backed remote document cache decorators,
- separate context loader support on federation contexts,
- configurable built-in document-loader user agent,
- authenticated document loaders for signed GET fetches,
- personal and shared-inbox authenticated document loaders,
- collection traversal across
first/nextpages, - Fedify-style best-effort collection traversal with suppressed page errors,
- object URI construction and local URI parsing,
- Fedify-style
get_actorandget_objectcontext helpers, - simple and reserved URI template expansion,
- ActivityStreams JSON-LD builders,
- ForgeFed repository, project, branch, commit, tag, push, ticket, ticket-tracker, patch-tracker, merge-request, and typed activity builders,
- marketplace offer/service/listing plus FEP-0837 proposal/agreement builders and strict validators,
- Ed25519/Multikey
eddsa-jcs-2022Object Integrity Proof helpers, - remote Multikey fetching/caching for proof verification,
- RSA-backed
DataIntegrityProofhelpers for local canonical JSON proofs, - in-memory, Redis, and SQL (SQLite/PostgreSQL) KV/queue helpers,
- OpenMetrics/Prometheus metrics telemetry exporter,
- WebFinger and NodeInfo builders,
- NodeInfo client lookup with Fedify-style JRD link ordering, URI inputs, origin-root discovery, direct, raw, and typed modes,
- custom WebFinger and NodeInfo dispatchers,
- inbox idempotency with
per-inbox,per-origin,global, and custom strategies, - basic inbox verification hooks,
- cursor and offset outbox paging,
- offset paging for named collection dispatchers,
- simple test hooks and sent-activity tracking.
Still future work:
- broader JSON-LD typed class coverage,
- durable remote document caches,
- broader RFC 9421 response-signature coverage beyond request signing and challenge retries.
For a feature-by-feature breakdown against Fedify, including the ForgeFed and
marketplace (FEP-0837) helpers, see docs/FEDIFY_PARITY.md.
The repository ships POSIX sh hooks under .meta/hooks/ that check the spec —
formatting, a type-checking build, and the full crystal spec suite:
.meta/hooks/check-all.sh # format (warn) + build + spec
APTORK_STRICT_FORMAT=1 .meta/hooks/check-all.sh # also enforce formatting (CI)
ameba # optional static analysisSee .meta/hooks/README.md for details and how to wire
them up as a git pre-push hook. CI runs the same hooks via
.github/workflows/spec.yml.
The app provider uses Aptork::Transport, Aptork::PublishRequest, and
Aptork::DeliveryConfig as a compatibility layer for the gateway provider.
New code should prefer Federation and Context#send_activity for Fedify-style
apps.
Following FEP-67ff, the repository ships a
FEDERATION.md document describing which ActivityPub, ForgeFed,
and FEP behaviors Aptork implements, the supported endpoints, and the
authentication mechanisms. Downstream applications embedding Aptork are
encouraged to provide their own FEDERATION.md describing their deployment.
Aptork is distributed under the BSD Zero Clause License (0BSD), a public-domain-equivalent license with no attribution requirement.