Skip to content

lion-lef/aptok

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Aptork

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 Federation and Context objects,
  • actor/object/outbox dispatchers,
  • followers/following/inbox/custom collection dispatchers,
  • typed inbox listeners,
  • framework-agnostic request routing,
  • Context#send_activity delivery,
  • 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.

Installation

dependencies:
  aptork:
    path: ./shards/aptork
require "aptork"

Federation

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")

Request Handling

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.body
server = 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)
end

Federation#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.com
  • GET/HEAD /.well-known/nodeinfo
  • GET/HEAD/POST /.well-known/apgateway/{did...}/{path}
  • GET/HEAD /nodeinfo/2.1 when set_nodeinfo_dispatcher is 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_dispatcher and set_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

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 Listeners

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.

Outbox Listeners

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.

Inbox Idempotency

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.

Inbox Queueing

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)
end

Manual 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))

Inbox Forwarding

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

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.

Access Control

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)

Actor Key Pairs

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.

Context URI Helpers

Aptork::Context mirrors the Fedify style of deriving stable URLs from route templates:

  • get_actor_uri(identifier)
  • get_inbox_uri(identifier)
  • get_inbox_uri for shared inbox
  • get_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_request
  • url
  • origin
  • canonical_origin
  • host
  • hostname
  • data

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}.

Remote Lookup

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_currency

TraverseCollectionOptions#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.

Vocabulary

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_TYPES
  • Aptork::OBJECT_TYPES
  • Aptork::ACTIVITY_TYPES
  • Aptork::FORGEFED_TYPES
  • Aptork::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_id

Fedify-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.stipulates

Object.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.

Sending Activities

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_queued

When 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_queued

Follower 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_inbox

exclude_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)

Transport and Signatures

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 as Accept-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.

Telemetry

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_prometheus

span 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.

Stores, Queues, and Discovery

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
end
redis = 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.

SQL stores (SQLite & PostgreSQL)

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
end

Federation#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 nodeinfo

Applications 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

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

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.

Current Scope Compared To Fedify

Implemented now:

  • Federation registry and Context URI helpers,
  • Crystal-style Aptork.federation setup DSL,
  • Fedify-style FederationOrigin handle/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 publicKey enrichment,
  • 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/next pages,
  • Fedify-style best-effort collection traversal with suppressed page errors,
  • object URI construction and local URI parsing,
  • Fedify-style get_actor and get_object context 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-2022 Object Integrity Proof helpers,
  • remote Multikey fetching/caching for proof verification,
  • RSA-backed DataIntegrityProof helpers 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.

Development

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 analysis

See .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.

Using From crater-openai

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.

Federation Metadata (FEP-67ff)

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.

License

Aptork is distributed under the BSD Zero Clause License (0BSD), a public-domain-equivalent license with no attribution requirement.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors