PageSourceSearch

https://owncast.online/assets/js/648a1ed3.121c3a05.js

js owncast.online collected 2026-10-02 06:23:30 UTC 24,929 bytes, 1 lines download raw bytes

1"use strict";(self.webpackChunkowncast_website=self.webpackChunkowncast_website||[]).push([["19030"],{21965(e,n,s){s.r(n),s.d(n,{metadata:()=>i,default:()=>h,frontMatter:()=>r,contentTitle:()=>d,toc:()=>a,assets:()=>c});var i=JSON.parse('{"id":"activitypub-and-federation-engineering-reference","title":"ActivityPub and Federation: Engineering Reference","description":"The internal reference for how Owncast\'s federation actually works: architecture, pipelines, wire formats, security decisions, storage, configuration and testing. The protocol-level summary lives in FEDERATION.md in the repo (FEP-67ff format) and the user-facing doc is on owncast.online under Social features. This doc is for people working on the code.","source":"@site/dev-docs/activitypub-and-federation-engineering-reference.md","sourceDirName":".","slug":"/activitypub-and-federation-engineering-reference","permalink":"/dev-docs/activitypub-and-federation-engineering-reference","draft":false,"unlisted":false,"editUrl":"https://project.owncast.tv/s/dev-docs/p/activitypub-and-federation-engineering-reference-kcwX012MbQ","tags":[{"inline":true,"label":"development","permalink":"/dev-docs/tags/development"}],"version":"current","frontMatter":{"title":"ActivityPub and Federation: Engineering Reference","slug":"/activitypub-and-federation-engineering-reference","displayed_sidebar":"devSidebar","tags":["development"],"custom_edit_url":"https://project.owncast.tv/s/dev-docs/p/activitypub-and-federation-engineering-reference-kcwX012MbQ"},"sidebar":"devSidebar","previous":{"title":"5th Anniversary Plans","permalink":"/dev-docs/5th-anniversary-plans"},"next":{"title":"Adding type-safe SQL queries","permalink":"/dev-docs/adding-type-safe-sql-queries"}}'),t=s(74848),o=s(28453);let r={title:"ActivityPub and Federation: Engineering Reference",slug:"/activitypub-and-federation-engineering-reference",displayed_sidebar:"devSidebar",tags:["development"],custom_edit_url:"https://project.owncast.tv/s/dev-docs/p/activitypub-and-federation-engineering-reference-kcwX012MbQ"},d,c={},a=[{value:"What Owncast is on the Fediverse",id:"what-owncast-is-on-the-fediverse",level:2},{value:"Architecture",id:"architecture",level:2},{value:"HTTP surface",id:"http-surface",level:2},{value:"Inbound pipeline",id:"inbound-pipeline",level:2},{value:"Outbound pipeline",id:"outbound-pipeline",level:2},{value:"Featured streams / directory",id:"featured-streams--directory",level:2},{value:"Quote posts (FEP-044f)",id:"quote-posts-fep-044f",level:2},{value:"Storage",id:"storage",level:2},{value:"Configuration",id:"configuration",level:2},{value:"Security model, condensed",id:"security-model-condensed",level:2},{value:"Testing",id:"testing",level:2},{value:"Known quirks",id:"known-quirks",level:2}];function l(e){let n={code:"code",h2:"h2",li:"li",ol:"ol",p:"p",strong:"strong",ul:"ul",...(0,o.R)(),...e.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(n.p,{children:"The internal reference for how Owncast's federation actually works: architecture, pipelines, wire formats, security decisions, storage, configuration and testing. The protocol-level summary lives in FEDERATION.md in the repo (FEP-67ff format) and the user-facing doc is on owncast.online under Social features. This doc is for people working on the code."}),"\n",(0,t.jsx)(n.h2,{id:"what-owncast-is-on-the-fediverse",children:"What Owncast is on the Fediverse"}),"\n",(0,t.jsxs)(n.p,{children:["Each Owncast instance is a single ActivityPub actor of type ",(0,t.jsx)(n.code,{children:"Service"})," (not ",(0,t.jsx)(n.code,{children:"Person"}),"), representing the stream. It is broadcast-only: it posts, it accepts followers, it acknowledges engagement, but it never follows anyone and ignores inbound posts. The ",(0,t.jsx)(n.code,{children:"following"})," collection returns 404."]}),"\n",(0,t.jsx)(n.p,{children:"Supported protocols: ActivityPub S2S, WebFinger, HTTP Signatures (cavage draft), NodeInfo. Supported FEPs: 67ff (FEDERATION.md), f1d5 (NodeInfo), 044f (quote posts, quoted-server side)."}),"\n",(0,t.jsx)(n.h2,{id:"architecture",children:"Architecture"}),"\n",(0,t.jsxs)(n.p,{children:["Everything lives under ",(0,t.jsx)(n.code,{children:"services/activitypub/"}),". ",(0,t.jsx)(n.code,{children:"activitypub.go"})," is the composition root: ",(0,t.jsx)(n.code,{children:"New(Deps)"})," wires the sub-services in dependency order, ",(0,t.jsx)(n.code,{children:"Start()"})," spawns worker pools and background jobs and generates the RSA signing keypair on first run, and ",(0,t.jsx)(n.code,{children:"Controllers()"})," hands the HTTP handler set to the router."]}),"\n",(0,t.jsx)(n.p,{children:"Sub-packages:"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"controllers/"}),": HTTP handlers (actor, inbox, outbox collection, single objects, webfinger, host-meta, nodeinfo variants, followers collection)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"inbox/"}),": inbound worker pool, signature verification and per-activity-type handlers."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"outbox/"}),": builds and addresses outgoing activities (go-live, manual posts, DMs, profile updates, directory pings)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"resolvers/"}),": JSON-to-vocab dispatch and remote IRI resolution (actors, public keys)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"requests/"}),": builders for signed responses and requests (follow accept/reject, quote accept/reject, signed writes)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"workerpool/"}),": outbound HTTP delivery pool with per-domain circuit breaking."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"crypto/"}),": RSA keypair and HTTP signature signing."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"apmodels/"}),": vocab construction helpers and IRI extraction utilities."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"persistence/"})," plus ",(0,t.jsx)(n.code,{children:"persistence/followersrepository/"}),": storage for outbox objects, followers and accepted activities."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"webfinger/"}),": outbound webfinger lookups (for DM targets)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"jobs/"}),": recurring background work, currently the stale featured-server checker."]}
1),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["The vocab library is the superseriousbusiness activity fork (",(0,t.jsx)(n.code,{children:"code.superseriousbusiness.org/activity"}),"), which extends go-fed with the gotosocial.org/ns vocabulary (interaction policies, quote types) plus toot/schema/funkwhale namespaces."]}),"\n",(0,t.jsx)(n.h2,{id:"http-surface",children:"HTTP surface"}),"\n",(0,t.jsxs)(n.p,{children:["From ",(0,t.jsx)(n.code,{children:"webserver/router/router.go"}),":"]}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"/federation/user/*"}),": the actor document, ",(0,t.jsx)(n.code,{children:"/federation/user/{username}/inbox"}),", outbox collection and followers collection. Gated by ",(0,t.jsx)(n.code,{children:"RequireActivityPubOrRedirect"}),": browsers get redirected to the home page, ",(0,t.jsx)(n.code,{children:"application/activity+json"})," clients get objects."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"/federation/*"}),": any single stored object by IRI (posts, quote authorization stamps), served from ",(0,t.jsx)(n.code,{children:"ap_outbox"})," by ",(0,t.jsx)(n.code,{children:"ObjectHandler"}),". 404s in private mode."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"/.well-known/webfinger"}),", ",(0,t.jsx)(n.code,{children:"/.well-known/host-meta"}),", ",(0,t.jsx)(n.code,{children:"/.well-known/nodeinfo"}),", ",(0,t.jsx)(n.code,{children:"/.well-known/x-nodeinfo2"}),", ",(0,t.jsx)(n.code,{children:"/nodeinfo/2.0"}),", ",(0,t.jsx)(n.code,{children:"/api/v1/instance"})," (Mastodon-compatible instance info)."]}),"\n"]}),"\n",(0,t.jsx)(n.p,{children:"All federation HTTP returns 405 when federation is disabled."}),"\n",(0,t.jsx)(n.h2,{id:"inbound-pipeline",children:"Inbound pipeline"}),"\n",(0,t.jsxs)(n.p,{children:[(0,t.jsx)(n.code,{children:"POST /federation/user/{username}/inbox"})," (controllers/inbox.go) checks federation is enabled and the username matches the configured federation username, reads the body, queues an ",(0,t.jsx)(n.code,{children:"InboxRequest"}),", and returns 202 immediately. A worker pool (",(0,t.jsx)(n.code,{children:"inbox/service.go"}),", sized to GOMAXPROCS) processes the queue."]}),"\n",(0,t.jsxs)(n.p,{children:["Each job goes through ",(0,t.jsx)(n.code,{children:"inbox/worker.go handle()"}),":"]}),"\n",(0,t.jsxs)(n.ol,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Signature verification"})," (",(0,t.jsx)(n.code,{children:"Verify"}),"): resolves the signing key from the request's keyId (fetching the remote actor if needed), verifies the RSA-SHA256 signature, checks the signed ",(0,t.jsx)(n.code,{children:"Date"})," against a generous replay window, and rejects blocked domains and blocked actors. Returns the verified key owner IRI."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Actor binding"}),": extracts the top-level ",(0,t.jsx)(n.code,{children:"actor"})," from the body and requires it to share an origin (hostname) with the verified key owner. This stops a server from signing with its own key while claiming, in the body, to be someone else. Fails closed when no actor IRI can be determined."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.strong,{children:"Dispatch"}),": ",(0,t.jsx)(n.code,{children:"streams.NewJSONResolver"})," routes by activity type to the registered handlers."]}),"\n"]}),"\n",(0,t.jsx)(n.p,{children:"Handlers and what they do:"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Follow"}),": stores the follower (approved automatically unless private mode), sends ",(0,t.jsx)(n.code,{children:"Accept(Follow)"}),", fires the follow webhook and optionally a chat message. A follow carrying the Owncast directory marker is a featured-streams directory relationship instead: always requires manual approval and is never surfaced as a fan follower."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Undo(Follow)"}),": removes the follower."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Like"})," / ",(0,t.jsx)(n.code,{children:"Announce"}),": engagement on one of our posts. Deduplicated per (object, actor, type), rejected for posts older than 36 hours, saved to ",(0,t.jsx)(n.code,{children:"ap_accepted_activities"}),", optionally surfaced in chat."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Update(Person)"}),": refreshes cached follower profile data."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Create"}),": ignored (we do not accept posts). There is chat-related handling for messages relayed from other Owncast instances."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Offer"})," / ",(0,t.jsx)(n.code,{children:"Leave"}
1),": featured-streams status pings from peer Owncast servers (see below)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"Accept"})," / ",(0,t.jsx)(n.code,{children:"Reject"}),": responses to things we sent (e.g. our directory follow requests)."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"QuoteRequest"}),": FEP-044f quote consent (see below)."]}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"outbound-pipeline",children:"Outbound pipeline"}),"\n",(0,t.jsxs)(n.p,{children:["Senders in ",(0,t.jsx)(n.code,{children:"outbox/"}),":"]}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"SendLive()"}),": the go-live ",(0,t.jsx)(n.code,{children:"Create(Note)"})," with the configured message, stream title, hashtags (toot:Hashtag linking to directory.owncast.online), a thumbnail/preview attachment, ",(0,t.jsx)(n.code,{children:"sensitive"})," when NSFW, and public addressing."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"SendPublicMessage()"}),": manual posts composed in the admin."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"SendDirectMessageToAccount()"}),": DMs, resolved via webfinger, addressed to the recipient with a ",(0,t.jsx)(n.code,{children:"Mention"})," tag for Mastodon compatibility."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"UpdateFollowersWithAccountUpdates()"}),": ",(0,t.jsx)(n.code,{children:"Update(Service)"})," on profile changes."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"SendStreamPing()"})," / ",(0,t.jsx)(n.code,{children:"SendStreamGoingOffline()"})," plus a ticker: ",(0,t.jsx)(n.code,{children:"Offer"}),"/",(0,t.jsx)(n.code,{children:"Leave"})," directory status pings (featured streams)."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["Addressing: public posts go ",(0,t.jsx)(n.code,{children:"to: as:Public, cc: {actor}/followers"}),". In private mode posts are addressed to followers only. Delivery prefers shared inboxes to cut request count."]}),"\n",(0,t.jsxs)(n.p,{children:["Every outgoing Note that is public also carries the FEP-044f ",(0,t.jsx)(n.code,{children:"interactionPolicy"})," (unless the operator disabled quoting)."]}),"\n",(0,t.jsxs)(n.p,{children:["Delivery goes through ",(0,t.jsx)(n.code,{children:"workerpool/"}),": a bounded pool (base size plus one worker per hundred followers, clamped) that POSTs signed requests. Repeated failures to a domain trip a per-domain circuit breaker with exponential backoff from 1 to 60 minutes."]}),"\n",(0,t.jsxs)(n.p,{children:["Outbound HTTP hygiene, applied everywhere: HTTPS-only inbox URLs, ",(0,t.jsx)(n.code,{children:"utils.IsHostnameInternal"})," SSRF rejection (shared ",(0,t.jsx)(n.code,{children:"validateRemoteInbox"})," helper in ",(0,t.jsx)(n.code,{children:"requests/"}),"), and ",(0,t.jsx)(n.code,{children:"utils.GetRetryableHTTPClient"})," for fetches, which does per-hop SSRF checks on redirects, caps redirect depth and enforces an 8 second timeout. Never hand-roll an ",(0,t.jsx)(n.code,{children:"http.Client"})," for federation calls."]}),"\n",(0,t.jsxs)(n.p,{children:["Everything we store for serving (posts, stamps) goes into ",(0,t.jsx)(n.code,{children:"ap_outbox"})," via ",(0,t.jsx)(n.code,{children:"outbox.Add"}),", keyed by IRI."]}),"\n",(0,t.jsx)(n.h2,{id:"featured-streams--directory",children:"Featured streams / directory"}),"\n",(0,t.jsx)(n.p,{children:"Peer Owncast servers can follow each other to build featured-stream directories. The pieces:"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsx)(n.li,{children:"A directory follow (Follow carrying the ns#directory marker) requires explicit admin approval and is excluded from follower counts and lists."}),"\n",(0,t.jsxs)(n.li,{children:["Live status flows via ",(0,t.jsx)(n.code,{children:"Offer"})," (live, also on a periodic ticker) and ",(0,t.jsx)(n.code,{children:"Leave"})," (offline) activities carrying Owncast metadata in unknown properties. Inbound metadata is clamped (string lengths, tag counts) before storage since it is attacker-controlled."]}),"\n",(0,t.jsxs)(n.li,{children:["Inbound pings update the ",(0,t.jsx)(n.code,{children:"federated_servers"})," table. A background job sweeps stale servers (offline threshold on the order of minutes)."]}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"quote-posts-fep-044f",children:"Quote posts (FEP-044f)"}),"\n",(0,t.jsx)(n.p,{children:"Owncast implements the quoted-server side of the consent flow used by Mastodon 4.5+ and GoToSocial. We never author quote posts ourselves."}),"\n",(0,t.jsx)(n.p,{children:"Flow:"}),"\n",(0,t.jsxs)(n.ol,{children:["\n",(0,t.jsxs)(n.li,{children:["Public Notes advertise quotability: ",(0,t.jsx)(n.code,{children:"interactionPolicy.canQuote.automaticApproval"})," set to the public collection."]}),"\n",(0,t.jsxs)(n.li,{children:["A remote server sends ",(0,t.jsx)(n.code,{children:"QuoteRequest {actor, object: our post, instrument: their quote post}"}),". Mastodon embeds the instrument as an object, GoToSocial sends an IRI. Both parse."]}),"\n",(0,t.jsxs)(n.li,{children:["If the object is one of our stored posts (a Note-only lookup, ",(0,t.jsx)(n.code,{children:"GetNoteByIRI"}),": stamps and pings in the same table are not quotable), federation is not private, and the operator allows quoting, we mint a ",(0,t.jsx)(n.code,{children:"QuoteAuthorization"})," stamp ",(0,t.jsx)(n.code,{children:"{attributedTo: us, interactingObject: quote post, interactionTarget: our post}"}),", store it in ",(0,t.jsx)(n.code,{children:"ap_outbox"}),", and reply ",(0,t.jsx)(n.code,{children:"Accept"})," with the embedded original request and ",(0,t.jsx)(n.code,{children:"result"})," set to the stamp IRI. Otherwise we reply ",(0,t.jsx)(n.code,{children:"Reject"}),"."]}),"\n",(0,t.jsxs)(n.li,{children:["Third-party servers verify quotes by fetching the stamp at its IRI from ",(0,t.jsx)(n.code,{children:"/federation/{id}"}),"."]}),"\n"]}),"\n",(0,t.jsxs)(n.p,{children:["Mastodon's validation (verified against their source, ",(0,t.jsx)(n.code,{children:"verify_quote_service.rb"})," and ",(0,t.jsx)(n.code,{children:"activity/accept.rb"}),"): raw string type match on ",(0,t.jsx)(n.code,{children:"QuoteAuthorization"}),", ",(0,t.jsx)(n.code,{children:"@context"})," must contain the AS namespace, the stamp must be same-host with its ",(0,t.jsx)(n.code,{children:"attributedTo"})," and match both post IRIs, and the Accept's ",(0,t.jsx)(n.code,{children:"result"})," must be same-host with the Accept's actor. Our library serializes bare type names with both context URIs, which passes all of it."]}),"\n",(0,t.jsx)(n.p,{children:"Notes:"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsx)(n.li,{children:"Redelivered QuoteRequests get a fresh Accept and stamp. Idempotent from the remote's view."}),"\n",(0,t.jsx)(n.li,{children:"Quotes are deliberately silent: no chat entry, no engagement feed, no webhook (product decision)."}),"\n",(0,t.jsxs)(n.li,{children:['The operator toggle is "Allow quotes" on the admin Social page, key ',(0,t.jsx)(n.code,{children:"federation_enable_quotes"}),", default on, endpoint ",(0,t.jsx)(n.code,{children:"POST /api/admin/config/federation/enablequotes"}),". Off means new posts drop the policy and all requests get Rejects."]}),"\n",(0,t.jsx)(n.li,{children:"No revocation: deleting a post does not Delete its stamps."}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"storage",children:"Storage"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"ap_outbox"})," (iri, value, type, live_notification, created_at): everything we serve b
1y IRI. Posts are stored as bare Notes. Also holds QuoteAuthorization stamps and Offer/Leave pings, so the NodeInfo ",(0,t.jsx)(n.code,{children:"localPosts"})," count and the public outbox collection are filtered to ",(0,t.jsx)(n.code,{children:"type = 'Note'"}),"."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"ap_followers"})," (iri, inbox, shared_inbox, name, username, image, request, request_object, approved_at, disabled_at, directory): followers and pending/rejected requests. ",(0,t.jsx)(n.code,{children:"directory"})," marks featured-streams relationships."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"ap_accepted_activities"})," (iri, actor, type, timestamp): inbound engagement, used for dedup and the admin activities view."]}),"\n",(0,t.jsxs)(n.li,{children:[(0,t.jsx)(n.code,{children:"federated_servers"}),": peer Owncast servers for featured streams."]}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"configuration",children:"Configuration"}),"\n",(0,t.jsxs)(n.p,{children:["Datastore keys (via configrepository): ",(0,t.jsx)(n.code,{children:"federation_enabled"}),", ",(0,t.jsx)(n.code,{children:"federation_username"})," (default ",(0,t.jsx)(n.code,{children:"streamer"}),"), ",(0,t.jsx)(n.code,{children:"federation_private"}),", ",(0,t.jsx)(n.code,{children:"federation_go_live_message"}),", ",(0,t.jsx)(n.code,{children:"federation_show_engagement"})," (default true), ",(0,t.jsx)(n.code,{children:"federation_enable_quotes"})," (default true), ",(0,t.jsx)(n.code,{children:"federation_hide_followers_tab"}),", ",(0,t.jsx)(n.code,{children:"federation_blocked_domains"}),". Server URL must be HTTPS for federation to be enabled."]}),"\n",(0,t.jsxs)(n.p,{children:["Private mode: ",(0,t.jsx)(n.code,{children:"manuallyApprovesFollowers"})," true on the actor, follows queue for approval, posts are follower-only, object serving 404s, and quote requests are rejected."]}),"\n",(0,t.jsxs)(n.p,{children:["Env vars for development only: ",(0,t.jsx)(n.code,{children:"OWNCAST_ALLOW_INTERNAL_FEDERATION=true"})," disables the internal-host SSRF guard, ",(0,t.jsx)(n.code,{children:"OWNCAST_INSECURE_SKIP_VERIFY=true"})," accepts self-signed TLS. Both are memoized via sync.Once, so tests must set them before anything reads them."]}),"\n",(0,t.jsx)(n.h2,{id:"security-model-condensed",children:"Security model, condensed"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsx)(n.li,{children:"All inbound inbox POSTs require a valid HTTP signature from a resolvable key. Unsigned or badly signed requests are dropped."}),"\n",(0,t.jsx)(n.li,{children:"Replay bounded by the signed Date window. Missing/unparseable dates are tolerated for interop, so replay bounding applies to well-behaved senders."}),"\n",(0,t.jsx)(n.li,{children:"The activity's actor is bound to the signing key by origin."}),"\n",(0,t.jsx)(n.li,{children:"Domain and actor blocklists are enforced at verification time, before any handler runs."}),"\n",(0,t.jsx)(n.li,{children:"Outbound: HTTPS-only, internal-host rejection, redirect-hop SSRF checks, bounded timeouts, circuit breaking."}),"\n",(0,t.jsx)(n.li,{children:"Remote metadata (featured-streams pings) is length/count clamped before storage."}),"\n",(0,t.jsxs)(n.li,{children:["Known gaps, deliberately deferred: ",(0,t.jsx)(n.code,{children:"isIPAddressInternal"})," misses link-local 169.254/16 (cloud metadata), CGNAT 100.64/10, multicast and 0.0.0.0, and DNS is resolved separately from dial (rebinding TOCTOU)."]}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"testing",children:"Testing"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsx)(n.li,{children:"Unit tests live beside the code (inbox binding/nil-safety, offer clamping, quote wire formats, resolver dispatch of Mastodon's exact QuoteRequest JSON)."}),"\n",(0,t.jsxs)(n.li,{children:["CI runs dedicated ActivityPub suites (federation, follower validation, chat sanitization, fediverse auth, featured streams) from ",(0,t.jsx)(n.code,{children:"test/automated/"}),", against a real built instance."]}),"\n",(0,t.jsx)(n.li,{children:"For manual end-to-end work there is a fake-remote-server harness recipe: a throwaway Go program that serves a self-signed actor plus inbox, signs an activity with go-fed/httpsig and posts it to a local instance, then prints what Owncast delivers back. Run the instance with the two dev env vars above. This exercises the full verify-dispatch-respond loop for any activity type without the heavy two-instance environment."}),"\n",(0,t.jsxs)(n.li,{children:["The two-instance docker dev environment lives at ",(0,t.jsx)(n.code,{children:"test/automated/activitypub/dev-up.sh"})," when full server-to-server behavior is needed."]}),"\n"]}),"\n",(0,t.jsx)(n.h2,{id:"known-quirks",children:"Known quirks"}),"\n",(0,t.jsxs)(n.ul,{children:["\n",(0,t.jsxs)(n.li,{children:["The public outbox collection deserializer only appends ",(0,t.jsx)(n.code,{children:"Create"})," activities but posts are stored as bare Notes, so the collection has been effectively empty for a long time. Filtering to Notes did not change that. Worth fixing someday if anyone notices."]}),"\n",(0,t.jsxs)(n.li,{children:["The ",(0,t.jsx)(n.code,{children:"eu"})," locale and other non-federation quirks aside, ",(0,t.jsx)(n.code,{children:"GetOutbox"})," pagination and ",(0,t.jsx)(n.code,{children:"localPosts"})," are now Note-only by design."]}),"\n",(0,t.jsx)(n.li,{children:"Bots commit to PR branches (translation extraction, API docs), so rebase before re-pushing federation PRs."}),"\n"]})]})}function h(e={}){let{wrapper:n}={...(0,o.R)(),...e.components};return n?(0,t.jsx)(n,{...e,children:(0,t.jsx)(l,{...e})}):l(e)}},28453(e,n,s){s.d(n,{R:()=>r,x:()=>d});var i=s(96540);let t={},o=i.createContext(t);function r(e){let n=i.useContext(o);return i.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function d(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(t):e.components||t:r(e.components),i.createElement(o.Provider,{value:n},e.children)}}}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.