New Sign in

fierj

Public

Tiny personal git forge

aa2e312716d1d1a55b611dad7e73e6ea250ca41a
diff --git a/ap.go b/ap.go
index 1e905b6..bfcb661 100644
--- a/ap.go
+++ b/ap.go
@@ -1,24 +1,1000 @@
 package main
 
-import "log/slog"
+import (
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
 
 const apContext = "https://www.w3.org/ns/activitystreams"
+const apPublic = "https://www.w3.org/ns/activitystreams#Public"
+const apContentType = "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\""
+const jrdContentType = "application/jrd+json"
 
-type APObject struct {
-	Context      string   `json:"@context"`
-	ID           string   `json:"id"`
+// ----- data types -----
+
+// APActor represents an ActivityPub actor (Service type) for a repo.
+type APActor struct {
+	Context           string `json:"@context"`
+	ID                string `json:"id"`
+	Type              string `json:"type"`
+	PreferredUsername string `json:"preferredUsername"`
+	Name              string `json:"name,omitempty"`
+	Summary           string `json:"summary,omitempty"`
+	URL               string `json:"url,omitempty"`
+	Inbox             string `json:"inbox"`
+	Outbox            string `json:"outbox"`
+	Followers         string `json:"followers"`
+	PublicKey         APKey  `json:"publicKey"`
+}
+
+// APKey is the public key block embedded in the actor.
+type APKey struct {
+	ID           string `json:"id"`
+	Owner        string `json:"owner"`
+	PublicKeyPem string `json:"publicKeyPem"`
+}
+
+// APActivity is a generic ActivityPub activity.
+type APActivity struct {
+	Context string `json:"@context"`
+	ID      string `json:"id,omitempty"`
+	Type    string `json:"type"`
+	Actor   string `json:"actor,omitempty"`
+	Object  any    `json:"object,omitempty"`
+}
+
+// APCreate wraps a Create activity.
+type APCreate struct {
+	Context   string   `json:"@context"`
+	ID        string   `json:"id"`
+	Type      string   `json:"type"`
+	Actor     string   `json:"actor"`
+	Published string   `json:"published"`
+	To        []string `json:"to"`
+	CC        []string `json:"cc,omitempty"`
+	Object    *APNote  `json:"object"`
+}
+
+// APNote is a Note object (used for threads/replies).
+type APNote struct {
+	ID           string   `json:"id,omitempty"`
 	Type         string   `json:"type"`
 	Published    string   `json:"published,omitempty"`
 	AttributedTo string   `json:"attributedTo,omitempty"`
 	Content      string   `json:"content,omitempty"`
 	URL          string   `json:"url,omitempty"`
+	InReplyTo    string   `json:"inReplyTo,omitempty"`
 	To           []string `json:"to,omitempty"`
 	CC           []string `json:"cc,omitempty"`
 }
 
+// APOrderedCollection is used for outbox and followers.
+type APOrderedCollection struct {
+	Context    string `json:"@context"`
+	ID         string `json:"id"`
+	Type       string `json:"type"`
+	TotalItems int    `json:"totalItems"`
+	First      string `json:"first,omitempty"`
+	OrderedItems []any `json:"orderedItems,omitempty"`
+}
+
+// APMeta holds per-repo ActivityPub state, stored in .forge/ap.json.
+type APMeta struct {
+	PublicKey  string   `json:"public_key"`
+	PrivateKey string   `json:"private_key"`
+	Followers  []string `json:"followers,omitempty"`
+}
+
+// ----- key management -----
+
+func apMetaPath(repoPath string) string {
+	return filepath.Join(repoPath, ".forge", "ap.json")
+}
+
+var apMetaLocks sync.Map
+
+func loadOrCreateAPMeta(repoPath string) (*APMeta, error) {
+	mu := &sync.Mutex{}
+	actual, _ := apMetaLocks.LoadOrStore(repoPath, mu)
+	mu = actual.(*sync.Mutex)
+	mu.Lock()
+	defer mu.Unlock()
+
+	dir := filepath.Dir(apMetaPath(repoPath))
+	os.MkdirAll(dir, 0755)
+
+	data, err := os.ReadFile(apMetaPath(repoPath))
+	if err == nil {
+		var meta APMeta
+		if json.Unmarshal(data, &meta) == nil && meta.PrivateKey != "" {
+			return &meta, nil
+		}
+	}
+
+	// Generate an RSA 2048-bit keypair (the de facto AP standard).
+	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, fmt.Errorf("generate key: %w", err)
+	}
+
+	pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+	if err != nil {
+		return nil, fmt.Errorf("marshal pub: %w", err)
+	}
+	pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
+
+	privBytes, err := x509.MarshalPKCS8PrivateKey(rsaKey)
+	if err != nil {
+		return nil, fmt.Errorf("marshal priv: %w", err)
+	}
+	privPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
+
+	meta := &APMeta{
+		PublicKey:  string(pubPEM),
+		PrivateKey: string(privPEM),
+		Followers:  nil,
+	}
+
+	b, _ := json.MarshalIndent(meta, "", "  ")
+	tmp := apMetaPath(repoPath) + ".tmp"
+	os.WriteFile(tmp, b, 0600)
+	os.Rename(tmp, apMetaPath(repoPath))
+	return meta, nil
+}
+
+func parseEd25519Private(pemData string) (ed25519.PrivateKey, error) {
+	priv, err := parsePrivateKey(pemData)
+	if err != nil {
+		return nil, err
+	}
+	ed, ok := priv.(ed25519.PrivateKey)
+	if !ok {
+		return nil, fmt.Errorf("not ed25519")
+	}
+	return ed, nil
+}
+
+// parsePrivateKey parses a PEM-encoded private key, returning either
+// *rsa.PrivateKey or ed25519.PrivateKey.
+func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
+	block, _ := pem.Decode([]byte(pemData))
+	if block == nil {
+		return nil, fmt.Errorf("bad PEM")
+	}
+	priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	switch priv.(type) {
+	case *rsa.PrivateKey, ed25519.PrivateKey:
+		return priv, nil
+	default:
+		return nil, fmt.Errorf("unsupported key type: %T", priv)
+	}
+}
+
+// parsePublicKey parses a PEM-encoded public key, returning either an
+// *rsa.PublicKey or ed25519.PublicKey.
+func parsePublicKey(pemData string) (crypto.PublicKey, error) {
+	block, _ := pem.Decode([]byte(pemData))
+	if block == nil {
+		return nil, fmt.Errorf("bad PEM")
+	}
+	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	switch pub.(type) {
+	case *rsa.PublicKey, ed25519.PublicKey:
+		return pub, nil
+	default:
+		return nil, fmt.Errorf("unsupported key type: %T", pub)
+	}
+}
+
+func parseEd25519Public(pemData string) (ed25519.PublicKey, error) {
+	pub, err := parsePublicKey(pemData)
+	if err != nil {
+		return nil, err
+	}
+	ed, ok := pub.(ed25519.PublicKey)
+	if !ok {
+		return nil, fmt.Errorf("not ed25519")
+	}
+	return ed, nil
+}
+
+// ----- URL helpers -----
+
 func actorURL(host, repoName string) string {
 	return "https://" + host + "/" + repoName + "/ap/actor"
 }
+func inboxURL(host, repoName string) string {
+	return "https://" + host + "/" + repoName + "/ap/inbox"
+}
+func outboxURL(host, repoName string) string {
+	return "https://" + host + "/" + repoName + "/ap/outbox"
+}
+func followersURL(host, repoName string) string {
+	return "https://" + host + "/" + repoName + "/ap/followers"
+}
+
+// ----- WebFinger -----
+
+type jrd struct {
+	Subject string          `json:"subject"`
+	Links   []jrdLink       `json:"links"`
+}
+type jrdLink struct {
+	Rel  string `json:"rel"`
+	Type string `json:"type,omitempty"`
+	Href string `json:"href,omitempty"`
+}
+
+func WebFinger(cfg Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if cfg.Host == "" {
+			http.NotFound(w, r)
+			return
+		}
+		resource := r.URL.Query().Get("resource")
+		if resource == "" || !strings.HasPrefix(resource, "acct:") {
+			http.NotFound(w, r)
+			return
+		}
+		acct := strings.TrimPrefix(resource, "acct:")
+		// Format: repo@host
+		parts := strings.SplitN(acct, "@", 2)
+		// Accept if the acct host matches our configured host, or if our
+		// host isn't set (development), accept the request's Host header.
+		if len(parts) != 2 {
+			http.NotFound(w, r)
+			return
+		}
+		acctHost := parts[1]
+		if acctHost != cfg.Host {
+			// Also accept if the acct uses the request's Host header
+			// (common behind reverse proxies).
+			if acctHost != r.Host && !strings.HasPrefix(r.Host, acctHost) {
+				http.NotFound(w, r)
+				return
+			}
+		}
+		repoName := parts[0]
+
+		// Verify repo exists.
+		repoPath := filepath.Join(cfg.Dir, repoName+".git")
+		if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+			http.NotFound(w, r)
+			return
+		}
+
+		result := jrd{
+			Subject: resource,
+			Links: []jrdLink{{
+				Rel:  "self",
+				Type: "application/activity+json",
+				Href: actorURL(cfg.Host, repoName),
+			}},
+		}
+
+		w.Header().Set("Content-Type", jrdContentType)
+		json.NewEncoder(w).Encode(result)
+	}
+}
+
+// ----- Actor -----
+
+func APActorHandler(cfg Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if cfg.Host == "" {
+			http.NotFound(w, r)
+			return
+		}
+		repoName := r.PathValue("repo")
+		repoPath := repoPath(cfg, repoName)
+
+		meta, err := loadOrCreateAPMeta(repoPath)
+		if err != nil {
+			slog.Error("ap actor: load meta", "repo", repoName, "error", err)
+			http.Error(w, "internal error", 500)
+			return
+		}
+
+		actorID := actorURL(cfg.Host, repoName)
+		git := &Git{Dir: cfg.Dir, Name: repoName}
+
+		actor := APActor{
+			Context:           apContext,
+			ID:                actorID,
+			Type:              "Service",
+			PreferredUsername: repoName,
+			Name:              repoName,
+			Summary:           git.Description(),
+			URL:               "https://" + cfg.Host + "/" + repoName,
+			Inbox:             inboxURL(cfg.Host, repoName),
+			Outbox:            outboxURL(cfg.Host, repoName),
+			Followers:         followersURL(cfg.Host, repoName),
+			PublicKey: APKey{
+				ID:           actorID + "#main-key",
+				Owner:        actorID,
+				PublicKeyPem: meta.PublicKey,
+			},
+		}
+
+		w.Header().Set("Content-Type", apContentType)
+		json.NewEncoder(w).Encode(actor)
+	}
+}
+
+// ----- Inbox -----
+
+func APInbox(cfg Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if cfg.Host == "" {
+			http.NotFound(w, r)
+			return
+		}
+		repoName := r.PathValue("repo")
+		repoPath := repoPath(cfg, repoName)
+
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, "bad request", 400)
+			return
+		}
+		defer r.Body.Close()
+
+		// Parse the activity first so we can use its actor field as a
+		// fallback for public key discovery.
+		var raw map[string]any
+		if err := json.Unmarshal(body, &raw); err != nil {
+			http.Error(w, "bad json", 400)
+			return
+		}
+		activityActor, _ := raw["actor"].(string)
+
+		// Verify HTTP signature. If verification fails, reject the activity.
+		sigHeader := r.Header.Get("Signature")
+		if sigHeader == "" {
+			http.Error(w, "missing signature", 401)
+			return
+		}
+
+		// Try RFC 9421 format first (sig1= in Signature + Signature-Input).
+		if sigInput := r.Header.Get("Signature-Input"); sigInput != "" && strings.Contains(sigHeader, "sig1=") {
+			if err := verifyRFC9421(sigHeader, sigInput, activityActor, r, body); err != nil {
+				slog.Warn("ap inbox: rfc9421 verify", "error", err)
+				http.Error(w, "invalid signature", 401)
+				return
+			}
+		} else {
+			// Legacy draft-cavage format.
+			sig, err := parseSignature(sigHeader)
+			if err != nil {
+				slog.Warn("ap inbox: bad signature header", "error", err)
+				http.Error(w, "invalid signature", 401)
+				return
+			}
+			pubKey, err := fetchPublicKeyFallback(sig.KeyID, activityActor)
+			if err != nil {
+				slog.Warn("ap inbox: fetch key", "keyId", sig.KeyID, "error", err)
+				http.Error(w, "cannot verify signature", 401)
+				return
+			}
+			if err := verifySignature(sig, pubKey, r, body); err != nil {
+				slog.Warn("ap inbox: signature verify", "error", err)
+				http.Error(w, "invalid signature", 401)
+				return
+			}
+		}
+
+		activityType, _ := raw["type"].(string)
+		actor, _ := raw["actor"].(string)
+
+		switch activityType {
+		case "Create":
+			obj, ok := raw["object"].(map[string]any)
+			if !ok {
+				http.Error(w, "bad object", 400)
+				return
+			}
+			objType, _ := obj["type"].(string)
+			if objType != "Note" {
+				http.Error(w, "unsupported object type", 400)
+				return
+			}
+			handleCreateNote(cfg, repoName, actor, obj)
+
+		case "Follow":
+			if actor != "" {
+				addFollower(repoPath, actor)
+				slog.Info("ap: new follower", "repo", repoName, "actor", actor)
+				// Deliver Accept to the follower's inbox (required by AP spec).
+				go sendAccept(cfg, repoName, actor, raw)
+				// Return 200 immediately; Mastodon considers the follow
+				// accepted when it receives the Accept in its inbox.
+				w.WriteHeader(200)
+				return
+			}
+
+		case "Undo":
+			obj, ok := raw["object"].(map[string]any)
+			if ok {
+				undoType, _ := obj["type"].(string)
+				if undoType == "Follow" {
+					undoActor, _ := obj["actor"].(string)
+					if undoActor != "" {
+						removeFollower(repoPath, undoActor)
+						slog.Info("ap: removed follower", "repo", repoName, "actor", undoActor)
+					}
+				}
+			}
+
+		default:
+			slog.Info("ap inbox: unhandled activity", "type", activityType)
+		}
+
+		w.WriteHeader(200)
+	}
+}
+
+// handleCreateNote processes an incoming Create/Note activity, creating a
+// thread or reply in the target repo.
+func handleCreateNote(cfg Config, repoName, actor string, obj map[string]any) {
+	content, _ := obj["content"].(string)
+	contentPreview := content
+	if len(contentPreview) > 200 {
+		contentPreview = contentPreview[:200]
+	}
+	slog.Info("ap inbox: create note", "actor", actor, "contentLen", len(content), "inReplyTo", obj["inReplyTo"], "content", contentPreview)
+	if content == "" {
+		return
+	}
+
+	// Content from Mastodon is HTML. Keep it for the body (rendered
+	// client-side), but strip tags for the title.
+	summary, _ := obj["summary"].(string)
+
+	rp := repoPath(cfg, repoName)
+
+	// Check if this is a reply to a fierj thread or reply. inReplyTo must be
+	// a fierj thread URL like https://host/repo/threads/<id>[#reply-<rid>].
+	inReplyTo, _ := obj["inReplyTo"].(string)
+	if inReplyTo != "" {
+		if idx := strings.Index(inReplyTo, "/threads/"); idx >= 0 {
+			rest := inReplyTo[idx+len("/threads/"):]
+			// Extract thread ID and optional parent reply ID from fragment.
+			threadID := rest
+			parentID := ""
+			if hash := strings.Index(rest, "#"); hash >= 0 {
+				threadID = rest[:hash]
+				frag := rest[hash+1:]
+				if strings.HasPrefix(frag, "reply-") {
+					parentID = strings.TrimPrefix(frag, "reply-")
+				}
+			}
+			if slash := strings.Index(threadID, "/"); slash >= 0 {
+				threadID = threadID[:slash]
+			}
+			if err := addNestedReply(rp, threadID, parentID, content, actor, actor); err != nil {
+				slog.Warn("ap inbox: reply failed", "thread", threadID, "error", err)
+				return
+			}
+			slog.Info("ap inbox: reply added", "repo", repoName, "thread", threadID, "parent", parentID)
+			return
+		}
+		// Not a fierj thread URL — treat as a new thread.
+	}
+
+	// Create a new thread. Use summary (Content Warning) as title when
+	// present, otherwise strip HTML from the first line of content.
+	title := summary
+	if title == "" {
+		title = stripHTML(content)
+		if idx := strings.Index(title, "\n"); idx >= 0 {
+			title = strings.TrimSpace(title[:idx])
+		}
+		if len(title) > 120 {
+			title = title[:120] + "…"
+		}
+	}
+
+	t, err := createThread(rp, title, content, actor, actor)
+	if err != nil {
+		slog.Warn("ap inbox: create thread failed", "error", err)
+		return
+	}
+	slog.Info("ap inbox: thread created", "repo", repoName, "thread", t.ID)
+}
+
+// stripHTML removes HTML tags from a string (simple approach for titles).
+func stripHTML(s string) string {
+	var out strings.Builder
+	inTag := false
+	for _, r := range s {
+		if r == '<' {
+			inTag = true
+		} else if r == '>' {
+			inTag = false
+		} else if !inTag {
+			out.WriteRune(r)
+		}
+	}
+	return strings.TrimSpace(out.String())
+}
+
+// ----- Outbox -----
+
+func APOutbox(cfg Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if cfg.Host == "" {
+			http.NotFound(w, r)
+			return
+		}
+		repoName := r.PathValue("repo")
+		rp := repoPath(cfg, repoName)
+		actor := actorURL(cfg.Host, repoName)
+
+		// Gather activities: thread creates from threads dir.
+		var items []any
+		threads, _, _ := listThreadsFiltered(rp, "")
+		for _, t := range threads {
+			threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+			note := APNote{
+				ID:           threadURL,
+				Type:         "Note",
+				Published:    t.Created,
+				AttributedTo: t.Author,
+				Content:      t.Title + "\n\n" + t.Body,
+				URL:          threadURL,
+				To:           []string{apPublic},
+				CC:           []string{followersURL(cfg.Host, repoName)},
+			}
+			items = append(items, map[string]any{
+				"id":        threadURL + "/activity",
+				"type":      "Create",
+				"actor":     actor,
+				"published": t.Created,
+				"to":        []string{apPublic},
+				"cc":        []string{followersURL(cfg.Host, repoName)},
+				"object":    note,
+			})
+		}
+
+		coll := APOrderedCollection{
+			Context:      apContext,
+			ID:           outboxURL(cfg.Host, repoName),
+			Type:         "OrderedCollection",
+			TotalItems:   len(items),
+			OrderedItems: items,
+		}
+
+		w.Header().Set("Content-Type", apContentType)
+		json.NewEncoder(w).Encode(coll)
+	}
+}
+
+// ----- Followers -----
+
+func APFollowers(cfg Config) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if cfg.Host == "" {
+			http.NotFound(w, r)
+			return
+		}
+		repoName := r.PathValue("repo")
+		repoPath := repoPath(cfg, repoName)
+
+		meta, err := loadOrCreateAPMeta(repoPath)
+		if err != nil {
+			slog.Error("ap followers: load meta", "repo", repoName, "error", err)
+			http.Error(w, "internal error", 500)
+			return
+		}
+
+		items := make([]any, len(meta.Followers))
+		for i, f := range meta.Followers {
+			items[i] = f
+		}
+
+		coll := APOrderedCollection{
+			Context:      apContext,
+			ID:           followersURL(cfg.Host, repoName),
+			Type:         "OrderedCollection",
+			TotalItems:   len(items),
+			OrderedItems: items,
+		}
+
+		w.Header().Set("Content-Type", apContentType)
+		json.NewEncoder(w).Encode(coll)
+	}
+}
+
+var followerLocks sync.Map
+
+func addFollower(repoPath, followerURL string) error {
+	mu := &sync.Mutex{}
+	actual, _ := followerLocks.LoadOrStore(repoPath, mu)
+	mu = actual.(*sync.Mutex)
+	mu.Lock()
+	defer mu.Unlock()
+
+	meta, err := loadOrCreateAPMeta(repoPath)
+	if err != nil {
+		return err
+	}
+	for _, f := range meta.Followers {
+		if f == followerURL {
+			return nil // already exists
+		}
+	}
+	meta.Followers = append(meta.Followers, followerURL)
+	return saveAPMeta(repoPath, meta)
+}
+
+func removeFollower(repoPath, followerURL string) error {
+	mu := &sync.Mutex{}
+	actual, _ := followerLocks.LoadOrStore(repoPath, mu)
+	mu = actual.(*sync.Mutex)
+	mu.Lock()
+	defer mu.Unlock()
+
+	meta, err := loadOrCreateAPMeta(repoPath)
+	if err != nil {
+		return err
+	}
+	filtered := make([]string, 0, len(meta.Followers))
+	for _, f := range meta.Followers {
+		if f != followerURL {
+			filtered = append(filtered, f)
+		}
+	}
+	meta.Followers = filtered
+	return saveAPMeta(repoPath, meta)
+}
+
+func saveAPMeta(repoPath string, meta *APMeta) error {
+	b, err := json.MarshalIndent(meta, "", "  ")
+	if err != nil {
+		return err
+	}
+	tmp := apMetaPath(repoPath) + ".tmp"
+	if err := os.WriteFile(tmp, b, 0600); err != nil {
+		return err
+	}
+	return os.Rename(tmp, apMetaPath(repoPath))
+}
+
+// ----- HTTP Signatures -----
+
+// httpSignature represents a parsed HTTP Signature header.
+type httpSignature struct {
+	KeyID     string
+	Algorithm string
+	Headers   []string
+	Signature []byte
+}
+
+// parseSignature parses an HTTP Signature header value.
+// Format: keyId="...",algorithm="...",headers="...",signature="..."
+func parseSignature(header string) (*httpSignature, error) {
+	s := &httpSignature{}
+	pairs := strings.Split(header, ",")
+	for _, pair := range pairs {
+		kv := strings.SplitN(strings.TrimSpace(pair), "=", 2)
+		if len(kv) != 2 {
+			continue
+		}
+		key := kv[0]
+		val := strings.Trim(kv[1], `"`)
+		switch key {
+		case "keyId":
+			s.KeyID = val
+		case "algorithm":
+			s.Algorithm = val
+		case "headers":
+			s.Headers = strings.FieldsFunc(val, func(r rune) bool { return r == ' ' })
+		case "signature":
+			sig, err := base64.StdEncoding.DecodeString(val)
+			if err != nil {
+				return nil, fmt.Errorf("bad signature base64: %w", err)
+			}
+			s.Signature = sig
+		}
+	}
+	if s.KeyID == "" || len(s.Signature) == 0 {
+		return nil, fmt.Errorf("incomplete signature")
+	}
+	if s.Algorithm == "" {
+		s.Algorithm = "rsa-sha256" // default
+	}
+	return s, nil
+}
+
+// fetchPublicKey fetches an actor's public key from the remote server.
+// Returns either *rsa.PublicKey or ed25519.PublicKey.
+func fetchPublicKey(keyID string) (crypto.PublicKey, error) {
+	return fetchPublicKeyFallback(keyID, "")
+}
+
+// fetchPublicKeyFallback tries the actor URL first (more stable than keyID
+// which can use Mastodon's newer /ap/users/<id> format that returns 403/410),
+// then falls back to the keyID URL.
+func fetchPublicKeyFallback(keyID, actorURL string) (crypto.PublicKey, error) {
+	if actorURL != "" {
+		key, err := fetchPublicKeyFromURL(actorURL)
+		if err == nil {
+			return key, nil
+		}
+		slog.Info("ap: actor fetch failed, trying keyID", "actor", actorURL, "keyId", keyID, "error", err)
+	}
+	if keyID != "" {
+		return fetchPublicKeyFromURL(keyID)
+	}
+	return nil, fmt.Errorf("no key source")
+}
+
+func fetchPublicKeyFromURL(url string) (crypto.PublicKey, error) {
+	// Strip fragment for HTTP request; the fragment identifies the key within
+	// the actor document, not a separate URL.
+	reqURL := url
+	if idx := strings.Index(reqURL, "#"); idx >= 0 {
+		reqURL = reqURL[:idx]
+	}
+
+	req, err := http.NewRequest("GET", reqURL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("fetch key: %w", err)
+	}
+	req.Header.Set("Accept", apContentType)
+	req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("fetch key: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("fetch key: status %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	var actor struct {
+		PublicKey struct {
+			PublicKeyPem string `json:"publicKeyPem"`
+		} `json:"publicKey"`
+	}
+	if err := json.Unmarshal(body, &actor); err != nil {
+		return nil, fmt.Errorf("parse actor: %w", err)
+	}
+
+	return parsePublicKey(actor.PublicKey.PublicKeyPem)
+}
+
+// verifyRFC9421 verifies an RFC 9421 HTTP Message Signature.
+// sigHeader is the Signature header (sig1=:BASE64URL:).
+// sigInput is the Signature-Input header listing covered components.
+func verifyRFC9421(sigHeader, sigInput, actorURL string, r *http.Request, body []byte) error {
+	// Parse Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=...;keyid="..."
+	// Find the parenthesized list of covered components.
+	open := strings.Index(sigInput, "(")
+	close := strings.Index(sigInput, ")")
+	if open < 0 || close < 0 || close < open {
+		return fmt.Errorf("bad Signature-Input format")
+	}
+	componentsStr := sigInput[open+1 : close]
+	components := strings.Fields(strings.ReplaceAll(componentsStr, "\"", ""))
+
+	// Extract created and keyid params.
+	created := ""
+	keyID := ""
+	params := sigInput[close+1:]
+	for _, p := range strings.Split(params, ";") {
+		p = strings.TrimSpace(p)
+		if strings.HasPrefix(p, "created=") {
+			created = strings.Trim(p[len("created="):], `"`)
+		}
+		if strings.HasPrefix(p, "keyid=") {
+			keyID = strings.Trim(p[len("keyid="):], `"`)
+		}
+	}
+
+	// Build the signing string components.
+	var lines []string
+	for _, comp := range components {
+		switch comp {
+		case "@method":
+			lines = append(lines, `"@method": `+r.Method)
+		case "@target-uri":
+			targetURI := "https://" + r.Host + r.URL.Path
+			lines = append(lines, `"@target-uri": `+targetURI)
+		case "content-digest":
+			cd := r.Header.Get("Content-Digest")
+			if cd == "" {
+				return fmt.Errorf("missing Content-Digest header")
+			}
+			lines = append(lines, `"content-digest": `+cd)
+		case "host":
+			lines = append(lines, `"host": `+r.Host)
+		case "date":
+			lines = append(lines, `"date": `+r.Header.Get("Date"))
+		default:
+			// Generic header field.
+			v := r.Header.Get(comp)
+			if v == "" {
+				return fmt.Errorf("missing header %q", comp)
+			}
+			lines = append(lines, `"`+comp+`": `+v)
+		}
+	}
+
+	// Build @signature-params line.
+	paramsLine := "("
+	for i, comp := range components {
+		if i > 0 {
+			paramsLine += " "
+		}
+		paramsLine += `"` + comp + `"`
+	}
+	paramsLine += ")"
+	if created != "" {
+		paramsLine += ";created=" + created
+	}
+	if keyID != "" {
+		paramsLine += ";keyid=\"" + keyID + "\""
+	}
+	lines = append(lines, `"@signature-params": `+paramsLine)
+
+	signingString := strings.Join(lines, "\n")
+
+	// Extract the signature from sigHeader: sig1=:BASE64URL:
+	sigStart := strings.Index(sigHeader, "sig1=:")
+	if sigStart < 0 {
+		return fmt.Errorf("no sig1 in Signature header")
+	}
+	sigStart += len("sig1=:")
+	sigEnd := strings.Index(sigHeader[sigStart:], ":")
+	if sigEnd < 0 {
+		return fmt.Errorf("bad sig1 format")
+	}
+	sigB64 := sigHeader[sigStart : sigStart+sigEnd]
+	sig, err := base64.StdEncoding.DecodeString(sigB64)
+	if err != nil {
+		// Fallback: URL-safe encoding.
+		sig, err = base64.RawURLEncoding.DecodeString(sigB64)
+		if err != nil {
+			return fmt.Errorf("decode sig1: %w", err)
+		}
+	}
+
+	// Fetch the public key.
+	pubKey, err := fetchPublicKeyFallback(keyID, actorURL)
+	if err != nil {
+		return fmt.Errorf("fetch key: %w", err)
+	}
+
+	// Verify.
+	hash := sha256.Sum256([]byte(signingString))
+	switch k := pubKey.(type) {
+	case ed25519.PublicKey:
+		if !ed25519.Verify(k, hash[:], sig) {
+			return fmt.Errorf("ed25519 signature mismatch")
+		}
+	case *rsa.PublicKey:
+		if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig); err != nil {
+			return fmt.Errorf("rsa signature mismatch: %w", err)
+		}
+	default:
+		return fmt.Errorf("unsupported key type: %T", pubKey)
+	}
+	return nil
+}
+
+// verifySignature checks the HTTP signature against the request.
+// pub may be *rsa.PublicKey or ed25519.PublicKey.
+func verifySignature(sig *httpSignature, pub crypto.PublicKey, r *http.Request, body []byte) error {
+	// Build the signing string from the listed headers.
+	var parts []string
+	for _, h := range sig.Headers {
+		if h == "(request-target)" {
+			target := strings.ToLower(r.Method) + " " + r.URL.Path
+			parts = append(parts, "(request-target): "+target)
+		} else if h == "host" {
+			parts = append(parts, "host: "+r.Host)
+		} else if h == "digest" {
+			parts = append(parts, "digest: "+r.Header.Get("Digest"))
+		} else {
+			parts = append(parts, h+": "+r.Header.Get(h))
+		}
+	}
+	signingString := strings.Join(parts, "\n")
+
+	// Hash the signing string.
+	hash := sha256.Sum256([]byte(signingString))
+
+	switch k := pub.(type) {
+	case ed25519.PublicKey:
+		if !ed25519.Verify(k, hash[:], sig.Signature) {
+			return fmt.Errorf("ed25519 signature mismatch")
+		}
+	case *rsa.PublicKey:
+		if err := rsa.VerifyPKCS1v15(k, crypto.SHA256, hash[:], sig.Signature); err != nil {
+			return fmt.Errorf("rsa signature mismatch: %w", err)
+		}
+	default:
+		return fmt.Errorf("unsupported key type: %T", pub)
+	}
+	return nil
+}
+
+// signRequest adds an HTTP Signature header to an outgoing request.
+func signRequest(req *http.Request, body []byte, keyID string, priv crypto.PrivateKey) error {
+	// Compute digest of body.
+	digestHash := sha256.Sum256(body)
+	digest := "SHA-256=" + base64.StdEncoding.EncodeToString(digestHash[:])
+	req.Header.Set("Digest", digest)
+
+	date := time.Now().UTC().Format(http.TimeFormat)
+	req.Header.Set("Date", date)
+
+	// Build signing string: (request-target), host, date, digest
+	target := strings.ToLower(req.Method) + " " + req.URL.Path
+	signingString := fmt.Sprintf("(request-target): %s\nhost: %s\ndate: %s\ndigest: %s",
+		target, req.URL.Host, date, digest)
+
+	hash := sha256.Sum256([]byte(signingString))
+
+	var sig []byte
+	var algo string
+	switch k := priv.(type) {
+	case ed25519.PrivateKey:
+		sig = ed25519.Sign(k, hash[:])
+		algo = "ed25519-sha256"
+	case *rsa.PrivateKey:
+		var err error
+		sig, err = rsa.SignPKCS1v15(rand.Reader, k, crypto.SHA256, hash[:])
+		if err != nil {
+			return fmt.Errorf("rsa sign: %w", err)
+		}
+		algo = "rsa-sha256"
+	default:
+		return fmt.Errorf("unsupported private key type: %T", priv)
+	}
+
+	sigHeader := fmt.Sprintf(
+		`keyId="%s",algorithm="%s",headers="(request-target) host date digest",signature="%s"`,
+		keyID, algo, base64.StdEncoding.EncodeToString(sig),
+	)
+	req.Header.Set("Signature", sigHeader)
+	return nil
+}
+
+// ----- Delivery to followers -----
+
+var httpClient = &http.Client{Timeout: 10 * time.Second}
 
 func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
 	if cfg.Host == "" {
@@ -26,28 +1002,245 @@ func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
 	}
 	actor := actorURL(cfg.Host, repoName)
 	threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
-	note := APObject{
-		Context:      apContext,
+
+	note := APNote{
 		ID:           threadURL,
 		Type:         "Note",
 		Published:    t.Created,
 		AttributedTo: actor,
 		Content:      t.Title + "\n\n" + t.Body,
 		URL:          threadURL,
-		To:           []string{"https://www.w3.org/ns/activitystreams#Public"},
-		CC:           []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+		To:           []string{apPublic},
+		CC:           []string{followersURL(cfg.Host, repoName)},
 	}
-	activity := map[string]any{
+	create := map[string]any{
 		"@context":  apContext,
 		"id":        threadURL + "/activity",
 		"type":      "Create",
 		"actor":     actor,
 		"published": t.Created,
-		"to":        []string{"https://www.w3.org/ns/activitystreams#Public"},
-		"cc":        []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+		"to":        []string{apPublic},
+		"cc":        []string{followersURL(cfg.Host, repoName)},
 		"object":    note,
 	}
-	// TODO: implement actual delivery to inboxes of followers.
-	slog.Info("ap: would deliver Create activity", "actor", actor, "thread", threadURL)
-	_ = activity
+
+	deliverToFollowers(cfg, repoName, create)
 }
+
+func deliverPatchCreate(cfg Config, repoName string, p *Patch) {
+	if cfg.Host == "" {
+		return
+	}
+	actor := actorURL(cfg.Host, repoName)
+	patchURL := "https://" + cfg.Host + "/" + repoName + "/patches/" + p.ID
+
+	note := APNote{
+		ID:           patchURL,
+		Type:         "Note",
+		Published:    p.Created,
+		AttributedTo: actor,
+		Content:      p.Title + "\n\n" + p.Body,
+		URL:          patchURL,
+		To:           []string{apPublic},
+		CC:           []string{followersURL(cfg.Host, repoName)},
+	}
+	create := map[string]any{
+		"@context":  apContext,
+		"id":        patchURL + "/activity",
+		"type":      "Create",
+		"actor":     actor,
+		"published": p.Created,
+		"to":        []string{apPublic},
+		"cc":        []string{followersURL(cfg.Host, repoName)},
+		"object":    note,
+	}
+
+	deliverToFollowers(cfg, repoName, create)
+}
+
+func deliverReplyCreate(cfg Config, repoName string, t *Thread) {
+	if cfg.Host == "" || len(t.Replies) == 0 {
+		return
+	}
+	actor := actorURL(cfg.Host, repoName)
+	last := t.Replies[len(t.Replies)-1]
+	threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+
+	// Determine the inReplyTo target: parent reply if nested, else thread root.
+	inReplyTo := threadURL
+	if last.ParentID != "" {
+		inReplyTo = threadURL + "#reply-" + last.ParentID
+	}
+
+	note := APNote{
+		ID:           threadURL + "#reply-" + last.ID,
+		Type:         "Note",
+		Published:    last.Created,
+		AttributedTo: actor,
+		Content:      last.Body,
+		URL:          threadURL + "#reply-" + last.ID,
+		InReplyTo:    inReplyTo,
+		To:           []string{apPublic},
+		CC:           []string{followersURL(cfg.Host, repoName)},
+	}
+	create := map[string]any{
+		"@context":  apContext,
+		"id":        threadURL + "#reply-" + last.ID + "/activity",
+		"type":      "Create",
+		"actor":     actor,
+		"published": last.Created,
+		"to":        []string{apPublic},
+		"cc":        []string{followersURL(cfg.Host, repoName)},
+		"object":    note,
+	}
+
+	deliverToFollowers(cfg, repoName, create)
+}
+
+func deliverToFollowers(cfg Config, repoName string, activity map[string]any) {
+	rp := repoPath(cfg, repoName)
+	meta, err := loadOrCreateAPMeta(rp)
+	if err != nil {
+		slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
+		return
+	}
+	if len(meta.Followers) == 0 {
+		slog.Info("ap deliver: no followers", "repo", repoName)
+		return
+	}
+
+	slog.Info("ap deliver: starting", "repo", repoName, "followers", len(meta.Followers))
+
+	priv, err := parsePrivateKey(meta.PrivateKey)
+	if err != nil {
+		slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
+		return
+	}
+
+	body, err := json.Marshal(activity)
+	if err != nil {
+		slog.Warn("ap deliver: marshal", "error", err)
+		return
+	}
+
+	for _, follower := range meta.Followers {
+		go func(followerURL string) {
+			// Resolve inbox from follower's actor.
+			inbox := followerURL
+			if !strings.Contains(followerURL, "/inbox") {
+				resolved, err := resolveInbox(followerURL)
+				if err != nil {
+					slog.Warn("ap deliver: resolve inbox", "actor", followerURL, "error", err)
+					return
+				}
+				inbox = resolved
+			}
+
+			req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
+			if err != nil {
+				return
+			}
+			req.Header.Set("Content-Type", "application/activity+json")
+			keyID := actorURL(cfg.Host, repoName) + "#main-key"
+			if err := signRequest(req, body, keyID, priv); err != nil {
+				slog.Warn("ap deliver: sign", "error", err)
+				return
+			}
+
+			resp, err := httpClient.Do(req)
+			if err != nil {
+				slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
+				return
+			}
+			resp.Body.Close()
+			slog.Info("ap deliver: delivered", "to", followerURL, "status", resp.StatusCode)
+		}(follower)
+	}
+}
+
+// sendAccept delivers an Accept activity to a follower's inbox.
+// Called asynchronously after a Follow is received.
+func sendAccept(cfg Config, repoName, actor string, followActivity map[string]any) {
+	accept := map[string]any{
+		"@context": apContext,
+		"type":     "Accept",
+		"actor":    actorURL(cfg.Host, repoName),
+		"object":   followActivity,
+	}
+	// Use the same delivery mechanism as thread/patch delivery.
+	deliverActivityToActor(cfg, repoName, actor, accept)
+}
+
+// deliverActivityToActor resolves an actor's inbox and delivers a signed activity.
+func deliverActivityToActor(cfg Config, repoName, actor string, activity map[string]any) {
+	rp := repoPath(cfg, repoName)
+	meta, err := loadOrCreateAPMeta(rp)
+	if err != nil {
+		slog.Warn("ap deliver: load meta", "repo", repoName, "error", err)
+		return
+	}
+	priv, err := parsePrivateKey(meta.PrivateKey)
+	if err != nil {
+		slog.Warn("ap deliver: parse key", "repo", repoName, "error", err)
+		return
+	}
+
+	inbox, err := resolveInbox(actor)
+	if err != nil {
+		slog.Warn("ap deliver: resolve inbox", "actor", actor, "error", err)
+		return
+	}
+
+	body, err := json.Marshal(activity)
+	if err != nil {
+		slog.Warn("ap deliver: marshal", "error", err)
+		return
+	}
+
+	req, err := http.NewRequest("POST", inbox, strings.NewReader(string(body)))
+	if err != nil {
+		return
+	}
+	req.Header.Set("Content-Type", "application/activity+json")
+	keyID := actorURL(cfg.Host, repoName) + "#main-key"
+	if err := signRequest(req, body, keyID, priv); err != nil {
+		slog.Warn("ap deliver: sign", "error", err)
+		return
+	}
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		slog.Warn("ap deliver: post", "inbox", inbox, "error", err)
+		return
+	}
+	resp.Body.Close()
+	slog.Info("ap deliver: delivered", "to", actor, "status", resp.StatusCode)
+}
+
+// resolveInbox fetches an actor and returns its inbox URL.
+func resolveInbox(actorURL string) (string, error) {
+	req, err := http.NewRequest("GET", actorURL, nil)
+	if err != nil {
+		return "", err
+	}
+	req.Header.Set("Accept", apContentType)
+	req.Header.Set("User-Agent", "fierj/0.1 (+https://git.zserge.com)")
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	var actor struct {
+		Inbox string `json:"inbox"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
+		return "", err
+	}
+	if actor.Inbox == "" {
+		return "", fmt.Errorf("no inbox in actor")
+	}
+	return actor.Inbox, nil
+}
+
diff --git a/ap_test.go b/ap_test.go
new file mode 100644
index 0000000..778104f
--- /dev/null
+++ b/ap_test.go
@@ -0,0 +1,860 @@
+package main
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"html/template"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// newTestServerWithHost creates a test server with Host configured from the
+// actual listener address (needed for ActivityPub URL construction).
+func newTestServerWithHost(t *testing.T) *testServer {
+	t.Helper()
+
+	dir := t.TempDir()
+	usersPath := filepath.Join(dir, "users.json")
+	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
+
+	// Build with placeholder host first, then rebuild after we know the port.
+	cfg := Config{
+		Addr:         "127.0.0.1:0",
+		Host:         "placeholder",
+		Dir:          dir,
+		UsersPath:    usersPath,
+		CookieSecret: "test-secret",
+	}
+
+	users, err := LoadUsers(usersPath)
+	if err != nil {
+		t.Fatalf("LoadUsers: %v", err)
+	}
+
+	secret := []byte(cfg.CookieSecret)
+	placeholderHandler := newHandler(cfg, users, secret, tmpl)
+
+	ts := httptest.NewUnstartedServer(placeholderHandler)
+	cfg.Host = ts.Listener.Addr().String()
+	handler := newHandler(cfg, users, secret, tmpl)
+	ts.Config.Handler = handler
+	ts.Start()
+
+	return &testServer{
+		Server: ts,
+		cfg:    cfg,
+		dir:    dir,
+		users:  users,
+		secret: secret,
+		tmpl:   tmpl,
+	}
+}
+
+// ---------- WebFinger ----------
+
+func TestWebFingerRepoDiscovery(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "ap-repo", "An AP-enabled repo")
+
+	resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:ap-repo@"+ts.cfg.Host)
+	assertStatus(t, resp, http.StatusOK)
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "jrd+json") {
+		t.Errorf("content-type should be jrd+json, got %q", ct)
+	}
+
+	var result jrd
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		t.Fatalf("parse WebFinger response: %v", err)
+	}
+
+	if result.Subject != "acct:ap-repo@"+ts.cfg.Host {
+		t.Errorf("subject = %q, want %q", result.Subject, "acct:ap-repo@"+ts.cfg.Host)
+	}
+	if len(result.Links) != 1 {
+		t.Fatalf("expected 1 link, got %d", len(result.Links))
+	}
+	link := result.Links[0]
+	if link.Rel != "self" {
+		t.Errorf("link rel = %q, want self", link.Rel)
+	}
+	if link.Type != "application/activity+json" {
+		t.Errorf("link type = %q", link.Type)
+	}
+	if link.Href != actorURL(ts.cfg.Host, "ap-repo") {
+		t.Errorf("link href = %q, want %q", link.Href, actorURL(ts.cfg.Host, "ap-repo"))
+	}
+}
+
+func TestWebFingerUnknownRepo(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "admin", "admin123")
+
+	resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:nonexistent@"+ts.cfg.Host)
+	assertStatus(t, resp, http.StatusNotFound)
+}
+
+func TestWebFingerBadResource(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "admin", "admin123")
+
+	resp := ts.get(t, c, "/.well-known/webfinger?resource=bad-format")
+	assertStatus(t, resp, http.StatusNotFound)
+}
+
+// ---------- Actor ----------
+
+func TestAPActor(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "actor-test", "Actor test repo")
+
+	resp := ts.get(t, c, "/actor-test/ap/actor")
+	assertStatus(t, resp, http.StatusOK)
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
+		t.Errorf("content-type should be activity+json, got %q", ct)
+	}
+
+	var actor APActor
+	if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
+		t.Fatalf("parse actor: %v", err)
+	}
+
+	if actor.Context != apContext {
+		t.Errorf("@context = %q", actor.Context)
+	}
+	if actor.Type != "Service" {
+		t.Errorf("type = %q, want Service", actor.Type)
+	}
+	if actor.ID != actorURL(ts.cfg.Host, "actor-test") {
+		t.Errorf("id = %q", actor.ID)
+	}
+	if actor.Name != "actor-test" {
+		t.Errorf("name = %q", actor.Name)
+	}
+	if actor.PreferredUsername != "actor-test" {
+		t.Errorf("preferredUsername = %q", actor.PreferredUsername)
+	}
+	if actor.URL != "https://"+ts.cfg.Host+"/actor-test" {
+		t.Errorf("url = %q", actor.URL)
+	}
+	if actor.Summary != "Actor test repo" {
+		t.Errorf("summary = %q", actor.Summary)
+	}
+	if actor.Inbox != inboxURL(ts.cfg.Host, "actor-test") {
+		t.Errorf("inbox = %q", actor.Inbox)
+	}
+	if actor.Outbox != outboxURL(ts.cfg.Host, "actor-test") {
+		t.Errorf("outbox = %q", actor.Outbox)
+	}
+	if actor.Followers != followersURL(ts.cfg.Host, "actor-test") {
+		t.Errorf("followers = %q", actor.Followers)
+	}
+	if actor.PublicKey.ID != actorURL(ts.cfg.Host, "actor-test")+"#main-key" {
+		t.Errorf("publicKey.id = %q", actor.PublicKey.ID)
+	}
+	if actor.PublicKey.Owner != actorURL(ts.cfg.Host, "actor-test") {
+		t.Errorf("publicKey.owner = %q", actor.PublicKey.Owner)
+	}
+	if !strings.Contains(actor.PublicKey.PublicKeyPem, "PUBLIC KEY") {
+		t.Error("publicKey.publicKeyPem should contain PEM block")
+	}
+}
+
+// ---------- Inbox ----------
+
+func TestAPInboxCreateNoteCreatesThread(t *testing.T) {
+	// Set up two servers: server A follows server B's repo.
+	// Server A = the receiver (has the inbox)
+	tsA := newTestServerWithHost(t)
+	cA := tsA.client()
+	tsA.setupUser(t, cA, "admin", "admin123")
+	tsA.createRepo(t, cA, "target", "Target repo")
+
+	// Get A's actor info for the key.
+	respActor := tsA.get(t, cA, "/target/ap/actor")
+	assertStatus(t, respActor, http.StatusOK)
+	var actorA APActor
+	json.NewDecoder(respActor.Body).Decode(&actorA)
+
+	// Build a Follow activity from "server B".
+	followActivity := map[string]any{
+		"@context": apContext,
+		"type":     "Follow",
+		"actor":    "https://remote.example/users/alice",
+		"object":   actorA.ID,
+	}
+
+	jsonBody, _ := json.Marshal(followActivity)
+	req, _ := http.NewRequest("POST", tsA.URL+"/target/ap/inbox", bytes.NewReader(jsonBody))
+	req.Header.Set("Content-Type", "application/activity+json")
+
+	// Create a fake signature (inbox currently requires a signature header).
+	// Since we can't sign externally, we'll test that a missing signature returns 401.
+	req.Header.Set("Signature", `keyId="https://remote.example/users/alice#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="deadbeef"`)
+
+	resp, err := tsA.client().Do(req)
+	if err != nil {
+		t.Fatalf("inbox request: %v", err)
+	}
+	// Strict verification: fake signature must be rejected.
+	assertStatus(t, resp, http.StatusUnauthorized)
+
+	// Now test that the inbox endpoint responds at all with a valid setup.
+	// (Full signature verification test would require signing with own key.)
+}
+
+// TestAPInboxNoSignature verifies that inbox rejects requests without a signature.
+func TestAPInboxNoSignature(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "admin", "admin123")
+	ts.createRepo(t, c, "sigtest", "Signature test")
+
+	body, _ := json.Marshal(map[string]string{"type": "Create"})
+	req, _ := http.NewRequest("POST", ts.URL+"/sigtest/ap/inbox", bytes.NewReader(body))
+	req.Header.Set("Content-Type", "application/activity+json")
+
+	resp, err := ts.client().Do(req)
+	if err != nil {
+		t.Fatalf("inbox request: %v", err)
+	}
+	assertStatus(t, resp, http.StatusUnauthorized)
+}
+
+// ---------- Outbox ----------
+
+func TestAPOutbox(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "outbox-test", "Outbox test")
+
+	// Create a thread so there's something in the outbox.
+	ts.postForm(t, c, "/outbox-test/threads/new", url.Values{
+		"title": {"Hello federation"},
+		"body":  {"First federated thread."},
+	})
+
+	resp := ts.get(t, c, "/outbox-test/ap/outbox")
+	assertStatus(t, resp, http.StatusOK)
+	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
+		t.Errorf("content-type should be activity+json, got %q", ct)
+	}
+
+	var coll APOrderedCollection
+	if err := json.NewDecoder(resp.Body).Decode(&coll); err != nil {
+		t.Fatalf("parse outbox: %v", err)
+	}
+
+	if coll.Type != "OrderedCollection" {
+		t.Errorf("type = %q, want OrderedCollection", coll.Type)
+	}
+	if coll.TotalItems < 1 {
+		t.Error("outbox should have at least 1 item")
+	}
+	// Check that an item contains our thread title.
+	found := false
+	for _, item := range coll.OrderedItems {
+		itemMap, ok := item.(map[string]any)
+		if !ok {
+			continue
+		}
+		obj, ok := itemMap["object"].(map[string]any)
+		if !ok {
+			continue
+		}
+		content, _ := obj["content"].(string)
+		if strings.Contains(content, "Hello federation") {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Error("outbox should contain the thread about 'Hello federation'")
+	}
+}
+
+func TestAPOutboxEmpty(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "empty-out", "Empty outbox")
+
+	resp := ts.get(t, c, "/empty-out/ap/outbox")
+	assertStatus(t, resp, http.StatusOK)
+
+	var coll APOrderedCollection
+	json.NewDecoder(resp.Body).Decode(&coll)
+	if coll.TotalItems != 0 {
+		t.Errorf("empty outbox should have 0 items, got %d", coll.TotalItems)
+	}
+}
+
+// ---------- Followers ----------
+
+func TestAPFollowers(t *testing.T) {
+	ts := newTestServerWithHost(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "followed", "Followed repo")
+
+	// Followers should start empty.
+	resp := ts.get(t, c, "/followed/ap/followers")
+	assertStatus(t, resp, http.StatusOK)
+
+	var coll APOrderedCollection
+	json.NewDecoder(resp.Body).Decode(&coll)
+	if coll.TotalItems != 0 {
+		t.Errorf("initial followers should be 0, got %d", coll.TotalItems)
+	}
+}
+
+// ---------- Key management ----------
+
+func TestAPKeyGeneration(t *testing.T) {
+	dir := t.TempDir()
+	repoPath := filepath.Join(dir, "keytest.git")
+	os.MkdirAll(repoPath, 0755)
+
+	meta, err := loadOrCreateAPMeta(repoPath)
+	if err != nil {
+		t.Fatalf("loadOrCreateAPMeta: %v", err)
+	}
+
+	if meta.PublicKey == "" || meta.PrivateKey == "" {
+		t.Fatal("keys should be generated")
+	}
+
+	// Parse the keys to ensure they're valid.
+	priv, err := parsePrivateKey(meta.PrivateKey)
+	if err != nil {
+		t.Fatalf("parse private key: %v", err)
+	}
+	pub, err := parsePublicKey(meta.PublicKey)
+	if err != nil {
+		t.Fatalf("parse public key: %v", err)
+	}
+
+	// Verify the keys match by signing with private and verifying with public.
+	msg := []byte("test message")
+	hash := sha256.Sum256(msg)
+	sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hash[:])
+	if err != nil {
+		t.Fatalf("sign: %v", err)
+	}
+	if err := rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, hash[:], sig); err != nil {
+		t.Error("keypair verification failed")
+	}
+
+	// Load again — should get the same keys.
+	meta2, err := loadOrCreateAPMeta(repoPath)
+	if err != nil {
+		t.Fatalf("second load: %v", err)
+	}
+	if meta2.PublicKey != meta.PublicKey || meta2.PrivateKey != meta.PrivateKey {
+		t.Error("keys should persist across loads")
+	}
+}
+
+// ---------- Follow/unfollow persistence ----------
+
+func TestAPFollowPersistence(t *testing.T) {
+	dir := t.TempDir()
+	repoPath := filepath.Join(dir, "followtest.git")
+	os.MkdirAll(repoPath, 0755)
+
+	// Add a follower.
+	if err := addFollower(repoPath, "https://example.com/users/alice"); err != nil {
+		t.Fatalf("addFollower: %v", err)
+	}
+
+	meta, _ := loadOrCreateAPMeta(repoPath)
+	if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/alice" {
+		t.Fatalf("followers = %v, want [alice]", meta.Followers)
+	}
+
+	// Add another.
+	addFollower(repoPath, "https://example.com/users/bob")
+	meta, _ = loadOrCreateAPMeta(repoPath)
+	if len(meta.Followers) != 2 {
+		t.Fatalf("followers count = %d, want 2", len(meta.Followers))
+	}
+
+	// Remove alice.
+	removeFollower(repoPath, "https://example.com/users/alice")
+	meta, _ = loadOrCreateAPMeta(repoPath)
+	if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/bob" {
+		t.Fatalf("followers after remove = %v, want [bob]", meta.Followers)
+	}
+
+	// Dedup.
+	addFollower(repoPath, "https://example.com/users/bob")
+	meta, _ = loadOrCreateAPMeta(repoPath)
+	if len(meta.Followers) != 1 {
+		t.Fatalf("followers should be deduplicated, got %d", len(meta.Followers))
+	}
+}
+
+// ---------- HTTP Signature parsing ----------
+
+func TestParseSignature(t *testing.T) {
+	header := `keyId="https://example.com/actor#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="c29tZXNpZ25hdHVyZQ=="`
+
+	sig, err := parseSignature(header)
+	if err != nil {
+		t.Fatalf("parseSignature: %v", err)
+	}
+	if sig.KeyID != "https://example.com/actor#main-key" {
+		t.Errorf("keyId = %q", sig.KeyID)
+	}
+	if sig.Algorithm != "ed25519-sha256" {
+		t.Errorf("algorithm = %q", sig.Algorithm)
+	}
+	if len(sig.Headers) != 4 {
+		t.Errorf("headers = %v", sig.Headers)
+	}
+	if len(sig.Signature) == 0 {
+		t.Error("signature is empty")
+	}
+}
+
+func TestParseSignatureIncomplete(t *testing.T) {
+	_, err := parseSignature(`keyId="https://example.com"`)
+	if err == nil {
+		t.Error("should fail for incomplete signature")
+	}
+}
+
+// TestRSASignatureRoundTrip verifies RSA key parsing and signature verification.
+func TestRSASignatureRoundTrip(t *testing.T) {
+	// Generate an RSA key (similar to what Mastodon uses).
+	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("generate RSA key: %v", err)
+	}
+
+	// Marshal public key to PEM.
+	pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
+
+	// Parse it back using parsePublicKey.
+	pub, err := parsePublicKey(pubPEM)
+	if err != nil {
+		t.Fatalf("parsePublicKey (RSA): %v", err)
+	}
+
+	// Build a fake request and sign it.
+	req, _ := http.NewRequest("POST", "https://example.com/inbox", bytes.NewReader([]byte(`{"type":"Follow"}`)))
+	req.Header.Set("Host", "example.com")
+	req.Header.Set("Date", "Sun, 06 Nov 1994 08:49:37 GMT")
+
+	body := []byte(`{"type":"Follow"}`)
+	digestHash := sha256.Sum256(body)
+	req.Header.Set("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(digestHash[:]))
+
+	// Build signing string.
+	target := "post /inbox"
+	signingString := "(request-target): " + target + "\nhost: example.com\ndate: Sun, 06 Nov 1994 08:49:37 GMT\ndigest: " + req.Header.Get("Digest")
+	hash := sha256.Sum256([]byte(signingString))
+
+	// Sign with RSA.
+	sig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, hash[:])
+	if err != nil {
+		t.Fatal(err)
+	}
+	sigB64 := base64.StdEncoding.EncodeToString(sig)
+
+	// Build signature header.
+	sigHeader := `keyId="https://example.com/actor#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="` + sigB64 + `"`
+
+	parsed, err := parseSignature(sigHeader)
+	if err != nil {
+		t.Fatalf("parseSignature: %v", err)
+	}
+
+	// Verify.
+	if err := verifySignature(parsed, pub, req, body); err != nil {
+		t.Fatalf("RSA verify failed: %v", err)
+	}
+
+	// Bad signature should fail.
+	parsed.Signature[0] ^= 0xff
+	if err := verifySignature(parsed, pub, req, body); err == nil {
+		t.Error("tampered signature should fail verification")
+	}
+}
+
+// TestBuildReplyViews verifies that a reply's "replying to" pointer only
+// shows up when it isn't simply continuing the line right above it.
+func TestBuildReplyViews(t *testing.T) {
+	replies := []Reply{
+		{ID: "r1", AuthorName: "Priya", Body: "top-level, replies to the thread root", ParentID: ""},
+		{ID: "r2", Body: "continues r1, the message right above it", ParentID: "r1"},
+		{ID: "r3", Body: "another top-level comment", ParentID: ""},
+		{ID: "r4", Body: "reaches back to r1, not the previous message", ParentID: "r1"},
+	}
+
+	views := buildReplyViews(replies)
+	if len(views) != 4 {
+		t.Fatalf("expected 4 views, got %d", len(views))
+	}
+	if views[0].ShowParent {
+		t.Error("reply to thread root should not show a pointer")
+	}
+	if views[1].ShowParent {
+		t.Error("reply to the immediately preceding message should not show a pointer")
+	}
+	if views[2].ShowParent {
+		t.Error("another top-level reply should not show a pointer")
+	}
+	if !views[3].ShowParent {
+		t.Error("reply that reaches back past the previous message should show a pointer")
+	}
+	if views[3].ParentAuthor != "Priya" {
+		t.Errorf("ParentAuthor = %q, want %q", views[3].ParentAuthor, "Priya")
+	}
+}
+
+// TestNestedReplyFromInbox verifies a nested reply (inReplyTo with #reply-<id>)
+// is stored with the correct ParentID.
+func TestNestedReplyFromInbox(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "nested-reply", "Nested reply test")
+
+	// Create a thread and a reply directly.
+	rp := repoPath(ts.cfg, "nested-reply")
+	thread, err := createThread(rp, "Thread", "Body", "dev", "dev")
+	if err != nil {
+		t.Fatal(err)
+	}
+	addReply(rp, thread.ID, "First reply", "dev", "dev")
+
+	// Load the thread to get the reply ID.
+	thread, _ = loadThread(rp, thread.ID)
+	if len(thread.Replies) != 1 {
+		t.Fatal("expected 1 reply")
+	}
+	replyID := thread.Replies[0].ID
+
+	// Simulate an AP reply with inReplyTo pointing to the reply.
+	inReplyTo := "https://" + ts.cfg.Host + "/nested-reply/threads/" + thread.ID + "#reply-" + replyID
+	obj := map[string]any{
+		"type":      "Note",
+		"content":   "Nested reply content",
+		"inReplyTo": inReplyTo,
+	}
+	handleCreateNote(ts.cfg, "nested-reply", "https://remote.example/users/bob", obj)
+
+	// Verify the nested reply has the correct ParentID.
+	thread, _ = loadThread(rp, thread.ID)
+	if len(thread.Replies) != 2 {
+		t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
+	}
+	last := thread.Replies[1]
+	if last.ParentID != replyID {
+		t.Errorf("nested reply ParentID = %q, want %q", last.ParentID, replyID)
+	}
+}
+
+// TestThreadViewLinear verifies the thread page renders replies as a single
+// chronological conversation, with a "replying to" pointer only on the one
+// that reaches back past the message immediately before it.
+func TestThreadViewLinear(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "views", "View test")
+
+	rp := repoPath(ts.cfg, "views")
+	thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
+	addReply(rp, thread.ID, "First reply", "dev", "dev")
+	thread, _ = loadThread(rp, thread.ID)
+	firstID := thread.Replies[0].ID
+	addReply(rp, thread.ID, "Second reply, top-level", "dev", "dev")
+	addNestedReply(rp, thread.ID, firstID, "Reaches back to the first reply", "dev", "dev")
+
+	resp := ts.get(t, c, "/views/threads/"+thread.ID)
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "First reply") || !strings.Contains(b, "Second reply, top-level") || !strings.Contains(b, "Reaches back to the first reply") {
+		t.Error("thread view should show all replies in one linear list")
+	}
+	if got := strings.Count(b, `class="reply-pointer"`); got != 1 {
+		t.Errorf("expected exactly one reply pointer (for the reply that skips back), got %d", got)
+	}
+}
+
+// TestReplyToSpecificComment verifies parent_id creates a nested reply via web UI.
+func TestReplyToSpecificComment(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "replies", "Reply test")
+
+	rp := repoPath(ts.cfg, "replies")
+	thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
+	addReply(rp, thread.ID, "First", "dev", "dev")
+	thread, _ = loadThread(rp, thread.ID)
+	firstReplyID := thread.Replies[0].ID
+
+	// Reply to the first reply via the web form.
+	resp := ts.postForm(t, c, "/replies/threads/"+thread.ID+"/reply", url.Values{
+		"body":      {"Nested from UI"},
+		"parent_id": {firstReplyID},
+	})
+	assertStatus(t, resp, http.StatusOK)
+
+	thread, _ = loadThread(rp, thread.ID)
+	if len(thread.Replies) != 2 {
+		t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
+	}
+	if thread.Replies[1].ParentID != firstReplyID {
+		t.Errorf("nested reply ParentID = %q, want %q", thread.Replies[1].ParentID, firstReplyID)
+	}
+}
+
+// ---------- End-to-end federation flow ----------
+
+func TestFederationEndToEnd(t *testing.T) {
+	// Server A hosts a repo that someone wants to follow and interact with.
+	tsA := newTestServerWithHost(t)
+	cA := tsA.client()
+	tsA.setupUser(t, cA, "admin", "admin123")
+	tsA.createRepo(t, cA, "fed-repo", "Federated repository")
+
+	// Get A's actor and public key.
+	respActorA := tsA.get(t, cA, "/fed-repo/ap/actor")
+	assertStatus(t, respActorA, http.StatusOK)
+	var actorA APActor
+	json.NewDecoder(respActorA.Body).Decode(&actorA)
+
+	// Load A's private key for signing.
+	rpA := repoPath(tsA.cfg, "fed-repo")
+	metaA, err := loadOrCreateAPMeta(rpA)
+	if err != nil {
+		t.Fatalf("load AP meta: %v", err)
+	}
+	privA, err := parsePrivateKey(metaA.PrivateKey)
+	if err != nil {
+		t.Fatalf("parse private key: %v", err)
+	}
+
+	// Construct URLs using http:// (test server is HTTP).
+	actorID := "http://" + tsA.cfg.Host + "/fed-repo/ap/actor"
+	inboxA := tsA.URL + "/fed-repo/ap/inbox"
+	keyID := actorID + "#main-key"
+
+	// Simulate "server B" — a remote user posting a Follow to A's inbox.
+	// We'll sign it with A's own key (since we are A and we have the private key).
+	// This lets us test the full pipeline: signature verification + activity dispatch.
+	followBody, _ := json.Marshal(map[string]any{
+		"@context": apContext,
+		"type":     "Follow",
+		"actor":    "https://remote.example/users/alice",
+		"object":   actorA.ID,
+	})
+
+	reqFollow, _ := http.NewRequest("POST", inboxA, bytes.NewReader(followBody))
+	reqFollow.Header.Set("Content-Type", "application/activity+json")
+	reqFollow.Header.Set("Host", tsA.cfg.Host)
+	if err := signRequest(reqFollow, followBody, keyID, privA); err != nil {
+		t.Fatalf("sign follow request: %v", err)
+	}
+
+	respFollow, err := tsA.client().Do(reqFollow)
+	if err != nil {
+		t.Fatalf("send follow: %v", err)
+	}
+	bodyStr := body(t, respFollow)
+	t.Logf("follow response status: %d, body: %.200s", respFollow.StatusCode, bodyStr)
+	// Should succeed (200 or 202).
+	if respFollow.StatusCode != 200 && respFollow.StatusCode != 202 {
+		t.Errorf("follow should return 200/202, got %d", respFollow.StatusCode)
+	}
+
+	// Verify alice is now a follower.
+	metaA, _ = loadOrCreateAPMeta(rpA)
+	found := false
+	for _, f := range metaA.Followers {
+		if f == "https://remote.example/users/alice" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Error("alice should be in followers after Follow activity")
+	}
+
+	// Now simulate Alice creating a Note (thread) via A's inbox.
+	noteBody, _ := json.Marshal(map[string]any{
+		"@context": apContext,
+		"type":     "Create",
+		"actor":    "https://remote.example/users/alice",
+		"object": map[string]any{
+			"type":         "Note",
+			"attributedTo": "https://remote.example/users/alice",
+			"content":      "Hello from Alice\n\nThis is a federated thread from a remote user.",
+		},
+	})
+
+	reqNote, _ := http.NewRequest("POST", inboxA, bytes.NewReader(noteBody))
+	reqNote.Header.Set("Content-Type", "application/activity+json")
+	reqNote.Header.Set("Host", tsA.cfg.Host)
+	if err := signRequest(reqNote, noteBody, keyID, privA); err != nil {
+		t.Fatalf("sign note request: %v", err)
+	}
+
+	respNote, err := tsA.client().Do(reqNote)
+	if err != nil {
+		t.Fatalf("send note: %v", err)
+	}
+	if respNote.StatusCode != 200 {
+		noteBodyStr := body(t, respNote)
+		t.Fatalf("note creation should return 200, got %d: %s", respNote.StatusCode, noteBodyStr)
+	}
+
+	// Verify the thread was created on server A.
+	rp := repoPath(tsA.cfg, "fed-repo")
+	threads, _, _ := listThreadsFiltered(rp, "open")
+	if len(threads) != 1 {
+		t.Fatalf("expected 1 thread from federation, got %d", len(threads))
+	}
+	if threads[0].Title != "Hello from Alice" {
+		t.Errorf("thread title = %q, want 'Hello from Alice'", threads[0].Title)
+	}
+	if threads[0].Author != "https://remote.example/users/alice" {
+		t.Errorf("thread author = %q, want remote user", threads[0].Author)
+	}
+
+	// Verify the thread is visible via the web UI.
+	respWeb := tsA.get(t, cA, "/fed-repo/threads/"+threads[0].ID)
+	assertStatus(t, respWeb, http.StatusOK)
+	b := body(t, respWeb)
+	if !strings.Contains(b, "Hello from Alice") {
+		t.Error("thread page should show federated thread title")
+	}
+	if !strings.Contains(b, "federated thread") {
+		t.Error("thread page should show federated thread body")
+	}
+
+	// Now simulate a reply via inbox.
+	replyBody, _ := json.Marshal(map[string]any{
+		"@context": apContext,
+		"type":     "Create",
+		"actor":    "https://remote.example/users/alice",
+		"object": map[string]any{
+			"type":         "Note",
+			"attributedTo": "https://remote.example/users/alice",
+			"content":      "This is a reply\n\nReplying to the federated thread.",
+			"inReplyTo":    "https://" + tsA.cfg.Host + "/fed-repo/threads/" + threads[0].ID,
+		},
+	})
+
+	reqReply, _ := http.NewRequest("POST", inboxA, bytes.NewReader(replyBody))
+	reqReply.Header.Set("Content-Type", "application/activity+json")
+	reqReply.Header.Set("Host", tsA.cfg.Host)
+	signRequest(reqReply, replyBody, keyID, privA)
+
+	respReply, err := tsA.client().Do(reqReply)
+	if err != nil {
+		t.Fatalf("send reply: %v", err)
+	}
+	if respReply.StatusCode != 200 {
+		bodyStr = body(t, respReply)
+		t.Fatalf("reply should return 200, got %d: %s", respReply.StatusCode, bodyStr)
+	}
+
+	// Reload the thread and verify the reply.
+	thread, err := loadThread(rp, threads[0].ID)
+	if err != nil {
+		t.Fatalf("load thread: %v", err)
+	}
+	if len(thread.Replies) != 1 {
+		t.Fatalf("expected 1 reply, got %d", len(thread.Replies))
+	}
+	if thread.Replies[0].Author != "https://remote.example/users/alice" {
+		t.Errorf("reply author = %q", thread.Replies[0].Author)
+	}
+	if !strings.Contains(thread.Replies[0].Body, "Replying to the federated thread") {
+		t.Error("reply body should contain the reply text")
+	}
+
+	// Undo the Follow.
+	undoBody, _ := json.Marshal(map[string]any{
+		"@context": apContext,
+		"type":     "Undo",
+		"actor":    "https://remote.example/users/alice",
+		"object": map[string]any{
+			"type":   "Follow",
+			"actor":  "https://remote.example/users/alice",
+			"object": actorA.ID,
+		},
+	})
+
+	reqUndo, _ := http.NewRequest("POST", inboxA, bytes.NewReader(undoBody))
+	reqUndo.Header.Set("Content-Type", "application/activity+json")
+	reqUndo.Header.Set("Host", tsA.cfg.Host)
+	signRequest(reqUndo, undoBody, keyID, privA)
+
+	respUndo, err := tsA.client().Do(reqUndo)
+	if err != nil {
+		t.Fatalf("send undo: %v", err)
+	}
+	if respUndo.StatusCode != 200 {
+		bodyStr = body(t, respUndo)
+		t.Fatalf("undo should return 200, got %d: %s", respUndo.StatusCode, bodyStr)
+	}
+
+	// Alice should be removed from followers.
+	metaA, _ = loadOrCreateAPMeta(rpA)
+	for _, f := range metaA.Followers {
+		if f == "https://remote.example/users/alice" {
+			t.Error("alice should be removed after Undo Follow")
+			break
+		}
+	}
+}
+
+// ---------- No host config disables AP ----------
+
+func TestAPDisabledWithoutHost(t *testing.T) {
+	ts := newTestServer(t) // no Host configured
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "no-ap", "No AP")
+
+	// All AP endpoints should return 404.
+	for _, path := range []string{
+		"/.well-known/webfinger?resource=acct:no-ap@localhost",
+		"/no-ap/ap/actor",
+		"/no-ap/ap/outbox",
+		"/no-ap/ap/followers",
+	} {
+		resp := ts.get(t, c, path)
+		if resp.StatusCode != 404 {
+			t.Errorf("%s should return 404 when Host is not configured, got %d", path, resp.StatusCode)
+		}
+	}
+}
diff --git a/main.go b/main.go
index 8808917..0a6eeb3 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,13 @@ func registerRoutes(mux *http.ServeMux, cfg Config, users UserStore, cookieSecre
 	mux.HandleFunc("GET /{repo}/patches/{patchId}", PatchView(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
+
+	// ActivityPub
+	mux.HandleFunc("GET /.well-known/webfinger", WebFinger(cfg))
+	mux.HandleFunc("GET /{repo}/ap/actor", APActorHandler(cfg))
+	mux.HandleFunc("POST /{repo}/ap/inbox", APInbox(cfg))
+	mux.HandleFunc("GET /{repo}/ap/outbox", APOutbox(cfg))
+	mux.HandleFunc("GET /{repo}/ap/followers", APFollowers(cfg))
 }
 
 // newHandler creates a fully-wired HTTP handler from the given configuration.
@@ -65,9 +72,41 @@ func newHandler(cfg Config, users UserStore, cookieSecret []byte, tmpl *template
 		h = SetupRedirect(users)(h)
 		h = authMiddleware(cookieSecret)(h)
 	}
+	// Request logging.
+	h = requestLogMiddleware(h)
 	return h
 }
 
+// requestLogMiddleware logs every incoming request with method, path, status,
+// and which handler served it (or "unmatched" if nothing matched).
+func requestLogMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		lw := &loggingResponseWriter{ResponseWriter: w, status: 200}
+		next.ServeHTTP(lw, r)
+		dur := time.Since(start)
+		slog.Info("request",
+			"method", r.Method,
+			"path", r.URL.Path,
+			"query", r.URL.RawQuery,
+			"status", lw.status,
+			"duration", dur.String(),
+			"host", r.Host,
+			"remote", r.RemoteAddr,
+		)
+	})
+}
+
+type loggingResponseWriter struct {
+	http.ResponseWriter
+	status int
+}
+
+func (lw *loggingResponseWriter) WriteHeader(code int) {
+	lw.status = code
+	lw.ResponseWriter.WriteHeader(code)
+}
+
 func main() {
 	if len(os.Args) >= 2 && os.Args[1] == "hook" {
 		os.Exit(hookCmd(os.Args[2:]))
diff --git a/patch_handlers.go b/patch_handlers.go
index 0883e59..45f1c0e 100644
--- a/patch_handlers.go
+++ b/patch_handlers.go
@@ -101,6 +101,7 @@ func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
+		go deliverPatchCreate(cfg, git.Name, p)
 		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
 	}
 }
diff --git a/templates/base.html b/templates/base.html
index 3175c00..1a4b6c4 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1034,40 +1034,104 @@
             margin-bottom: var(--space-lg);
         }
 
+        /* Boxed card: used for the thread/patch root post only — the one
+           "document" on the page. Conversation replies use .thread-reply
+           below, which is deliberately flat. */
         .thread-comment {
+            background: var(--bg-subtle);
             border: 1px solid var(--border);
             border-radius: var(--radius);
-            margin-bottom: var(--space-md);
+            margin-bottom: var(--space-lg);
         }
 
         .thread-comment-header {
             display: flex;
             justify-content: space-between;
             padding: var(--space-sm) var(--space-md);
-            background: var(--bg-subtle);
             border-bottom: 1px solid var(--border);
             font-size: 0.85rem;
-            border-radius: var(--radius) var(--radius) 0 0;
+            color: var(--text-secondary);
         }
 
         .thread-comment-body {
             padding: var(--space-md);
         }
 
-        .thread-reply-form {
+        /* Conversation: a plain, indented list — no per-comment card. */
+        .thread-replies {
             margin-top: var(--space-lg);
         }
 
-        .thread-reply-form textarea {
-            width: 100%;
-            font-family: inherit;
-            font-size: 0.9rem;
-            padding: var(--space-sm);
-            border: 1px solid var(--border);
-            border-radius: var(--radius);
-            background: var(--bg);
+        .thread-reply {
+            padding: var(--space-md) 0;
+            border-bottom: 1px solid var(--border-subtle);
+        }
+
+        .thread-reply-header {
+            display: flex;
+            align-items: baseline;
+            gap: var(--space-xs);
+            font-size: 0.85rem;
+            color: var(--text-secondary);
+            margin-bottom: var(--space-xs);
+        }
+
+        .thread-reply-header strong {
             color: var(--text);
-            resize: vertical;
+        }
+
+        .thread-reply-header time {
+            margin-left: auto;
+        }
+
+        .reply-pointer {
+            display: block;
+            background: none;
+            border: none;
+            padding: 0;
+            font: inherit;
+            font-size: 0.78rem;
+            color: var(--text-tertiary);
+            cursor: pointer;
+            margin-bottom: var(--space-xs);
+        }
+
+        .reply-pointer:hover {
+            color: var(--accent);
+            text-decoration: underline;
+        }
+
+        .thread-reply.flash,
+        .thread-comment.flash {
+            animation: fj-flash 0.9s ease;
+        }
+
+        @keyframes fj-flash {
+            from { background: var(--accent-subtle); }
+            to { background: transparent; }
+        }
+
+        .thread-reply-body {
+            font-size: 0.92rem;
+        }
+
+        .thread-reply-actions {
+            margin-top: var(--space-xs);
+        }
+
+        .reply-link {
+            background: none;
+            border: none;
+            padding: 0;
+            font-size: 0.8rem;
+            font-weight: 500;
+            color: var(--text-secondary);
+            cursor: pointer;
+        }
+
+        .reply-link:hover {
+            color: var(--accent);
+            text-decoration: underline;
         }
 
         .thread-actions {
@@ -1094,6 +1158,133 @@
             margin-bottom: var(--space-md);
         }
 
+        /* Thread participation widget (web / Mastodon / email) */
+        .participate {
+            margin-top: var(--space-lg);
+            padding-top: var(--space-md);
+            border-top: 1px solid var(--border-subtle);
+            max-width: 34rem;
+        }
+
+        .participate-tabs {
+            display: flex;
+            gap: var(--space-md);
+            margin-bottom: var(--space-md);
+        }
+
+        .participate-tab {
+            background: none;
+            border: none;
+            padding: var(--space-xs) 0;
+            font-size: 0.85rem;
+            font-weight: 500;
+            color: var(--text-secondary);
+            cursor: pointer;
+            border-bottom: 2px solid transparent;
+        }
+
+        .participate-tab:hover {
+            color: var(--text);
+        }
+
+        .participate-tab:focus-visible {
+            outline: none;
+            color: var(--accent);
+        }
+
+        .participate-tab.active {
+            color: var(--text);
+            border-bottom-color: var(--accent);
+        }
+
+        .participate-hint {
+            font-size: 0.8rem;
+            color: var(--text-secondary);
+            margin-top: var(--space-sm);
+        }
+
+        .replying-to {
+            display: none;
+            align-items: center;
+            gap: var(--space-xs);
+            margin-bottom: var(--space-sm);
+            font-size: 0.82rem;
+            color: var(--text-secondary);
+            background: var(--bg-subtle);
+            border: 1px solid var(--border-subtle);
+            border-radius: var(--radius-sm);
+            padding: 5px 10px;
+            width: fit-content;
+        }
+
+        .replying-to.on {
+            display: flex;
+        }
+
+        .replying-to button {
+            background: none;
+            border: none;
+            color: var(--text-tertiary);
+            cursor: pointer;
+            font-size: 1rem;
+            line-height: 1;
+            padding: 0 0 0 4px;
+        }
+
+        .replying-to button:hover {
+            color: var(--text);
+        }
+
+        .fediverse-handle {
+            display: flex;
+            align-items: center;
+            gap: var(--space-xs);
+            margin: var(--space-sm) 0;
+        }
+
+        .fediverse-handle code {
+            background: var(--bg-subtle);
+            padding: 2px 8px;
+            border-radius: 3px;
+            font-size: 0.9rem;
+        }
+
+        .remote-follow-form {
+            display: flex;
+            gap: var(--space-sm);
+        }
+
+        .remote-follow-form input {
+            flex: 1;
+        }
+
+        .email-magic-form {
+            display: flex;
+            gap: var(--space-sm);
+        }
+
+        .email-magic-form input {
+            flex: 1;
+        }
+
+        .badge-soon {
+            display: inline-block;
+            font-size: 0.7rem;
+            font-weight: 600;
+            text-transform: uppercase;
+            letter-spacing: 0.03em;
+            color: var(--text-secondary);
+            background: var(--bg-subtle);
+            border: 1px solid var(--border);
+            border-radius: var(--radius-sm);
+            padding: 1px 6px;
+            margin-left: var(--space-xs);
+        }
+
+        .guest-name-field {
+            max-width: 16rem;
+        }
+
         .patch-branches-hint {
             font-size: 0.85rem;
             color: var(--text-secondary);
@@ -1202,6 +1393,32 @@
             el.classList.add('rendered-md');
             el.querySelectorAll('pre code').forEach(c => hljs.highlightElement(c));
         });
+
+        // Switches between "On the web" / "From Mastodon" / "By email" panels
+        // in the thread participation widget.
+        function fjTab(btn) {
+            const tabs = btn.closest('.participate-tabs');
+            const panel = btn.dataset.panel;
+            tabs.querySelectorAll('.participate-tab').forEach(b => b.classList.toggle('active', b === btn));
+            tabs.parentElement.querySelectorAll('.participate-panel').forEach(p => {
+                p.hidden = p.dataset.panel !== panel;
+            });
+        }
+
+        // Sends the visitor to their own Mastodon (or other ActivityPub app)
+        // instance to follow this repo's actor, using the standard
+        // "remote follow" redirect (authorize_interaction).
+        function fjRemoteFollow(form) {
+            const host = form.instance.value.trim().replace(/^https?:\/\//, '').split('/')[0];
+            if (!host || !/^[a-zA-Z0-9.-]+$/.test(host)) {
+                alert('Enter a valid instance domain, e.g. mastodon.social');
+                return false;
+            }
+            const actorUrl = form.dataset.actorUrl;
+            window.open('https://' + host + '/authorize_interaction?uri=' + encodeURIComponent(actorUrl),
+                '_blank', 'noopener');
+            return false;
+        }
     </script>
 </body>
 
diff --git a/templates/thread_list.html b/templates/thread_list.html
index 3b52c9c..71a5477 100644
--- a/templates/thread_list.html
+++ b/templates/thread_list.html
@@ -23,6 +23,10 @@
     </div>
     <a href="/{{.Repo}}/threads/new" class="btn btn-primary">New thread</a>
 </div>
+{{if .APEnabled}}
+<p class="participate-hint">🐘 Follow <code>@{{.Repo}}@{{.Host}}</code> on Mastodon to get new threads in your feed
+    and reply from there.</p>
+{{end}}
 
 {{if .Threads}}
 <div class="thread-list">
diff --git a/templates/thread_new.html b/templates/thread_new.html
index 026db52..b5d69c4 100644
--- a/templates/thread_new.html
+++ b/templates/thread_new.html
@@ -7,6 +7,12 @@
     <h2>New Thread</h2>
     <form method="POST" action="/{{.Repo}}/threads/new">
         <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+        {{if not .User}}
+        <div class="form-group">
+            <label for="guest_name">Your name (optional)</label>
+            <input type="text" id="guest_name" name="guest_name" placeholder="Anonymous" maxlength="60">
+        </div>
+        {{end}}
         <div class="form-group">
             <label for="title">Title</label>
             <input type="text" id="title" name="title" placeholder="Brief summary" required>
@@ -17,6 +23,13 @@
         </div>
         <button type="submit" class="btn btn-primary">Create thread</button>
     </form>
+    {{if not .User}}
+    <p class="participate-hint">Posting without an account attributes this thread to "Anonymous" unless you give a
+        name above. <a href="/login">Sign in</a> for a verified identity.
+        {{if .APEnabled}}You can also open a thread by mentioning <code>@{{.Repo}}@{{.Host}}</code> in a post on
+        Mastodon.{{end}}
+    </p>
+    {{end}}
 </div>
 {{template "foot" .}}
 
diff --git a/templates/thread_view.html b/templates/thread_view.html
index eed65a7..f862b52 100644
--- a/templates/thread_view.html
+++ b/templates/thread_view.html
@@ -14,7 +14,7 @@
 </div>
 
 <!-- Original post -->
-<div class="thread-comment">
+<div class="thread-comment" id="reply-root">
     <div class="thread-comment-header">
         <strong>{{.Thread.AuthorName}}</strong>
         <span>{{timeAgo .Thread.Created}}</span>
@@ -22,36 +22,143 @@
     <div class="thread-comment-body markdown-body">{{.Thread.Body}}</div>
 </div>
 
-<!-- Replies -->
-{{range .Thread.Replies}}
-<div class="thread-comment">
-    <div class="thread-comment-header">
-        <strong>{{.AuthorName}}</strong>
-        <span>{{timeAgo .Created}}</span>
+<!-- Replies: one linear, chronological conversation. A reply only points -->
+<!-- back at an earlier message when it isn't simply continuing the line -->
+<!-- right above it — that's the rare, explicit callback worth calling out. -->
+{{if .ReplyViews}}
+<div class="thread-replies">
+    {{range .ReplyViews}}
+    <div class="thread-reply" id="reply-{{.ID}}">
+        {{if .ShowParent}}
+        <button type="button" class="reply-pointer" data-jump="{{.ParentID}}">↩ replying to
+            {{.ParentAuthor}}</button>
+        {{end}}
+        <div class="thread-reply-header">
+            <strong>{{.AuthorName}}</strong>
+            <time>{{timeAgo .Created}}</time>
+        </div>
+        <div class="thread-reply-body markdown-body">{{.Body}}</div>
+        <div class="thread-reply-actions">
+            <button type="button" class="reply-link" data-reply-id="{{.ID}}"
+                data-reply-author="{{.AuthorName}}">Reply</button>
+        </div>
     </div>
-    <div class="thread-comment-body markdown-body">{{.Body}}</div>
+    {{end}}
 </div>
 {{end}}
 
-<!-- Reply form -->
-<div class="thread-reply-form">
-    <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply">
-        <input type="hidden" name="csrf_token" value="{{.CSRF}}">
-        <div class="form-group">
-            <textarea name="body" rows="4" placeholder="Leave a comment..."></textarea>
-        </div>
-        <div class="thread-actions">
-            {{if eq .Thread.State "open"}}
-            <button type="submit" class="btn btn-primary">Comment</button>
-            <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close" class="btn btn-secondary">Close
-                thread</button>
-            {{else}}
-            <button type="submit" class="btn btn-primary">Comment</button>
-            <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen" class="btn btn-secondary">Reopen
-                thread</button>
+<!-- Participate -->
+<div class="participate">
+    {{if not .User}}
+    <div class="participate-tabs" role="tablist">
+        <button type="button" class="participate-tab active" data-panel="web" onclick="fjTab(this)">On the
+            web</button>
+        {{if .APEnabled}}
+        <button type="button" class="participate-tab" data-panel="mastodon" onclick="fjTab(this)">From
+            Mastodon</button>
+        {{end}}
+        <button type="button" class="participate-tab" data-panel="email" onclick="fjTab(this)">By email</button>
+    </div>
+    {{end}}
+
+    <div class="participate-panel" data-panel="web">
+        <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply" id="reply-form">
+            <input type="hidden" name="csrf_token" value="{{.CSRF}}">
+            <input type="hidden" name="parent_id" id="parent_id" value="">
+            <div class="replying-to" id="replying-to">
+                <span>Replying to <strong id="replying-to-name"></strong></span>
+                <button type="button" id="cancel-target" aria-label="Reply to the thread instead">✕</button>
+            </div>
+            {{if not .User}}
+            <div class="form-group">
+                <input type="text" id="guest_name" name="guest_name" placeholder="Your name (optional)"
+                    maxlength="60" class="guest-name-field">
+            </div>
             {{end}}
+            <div class="form-group">
+                <textarea name="body" id="reply-body" rows="4" placeholder="Leave a comment..."></textarea>
+            </div>
+            <div class="thread-actions">
+                {{if eq .Thread.State "open"}}
+                <button type="submit" class="btn btn-primary">Comment</button>
+                <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close"
+                    class="btn btn-secondary">Close
+                    thread</button>
+                {{else}}
+                <button type="submit" class="btn btn-primary">Comment</button>
+                <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen"
+                    class="btn btn-secondary">Reopen
+                    thread</button>
+                {{end}}
+            </div>
+        </form>
+        {{if not .User}}
+        <p class="participate-hint">Posting without an account attributes your comment to "Anonymous" unless you give
+            a name above. <a href="/login">Sign in</a> for a verified identity.</p>
+        {{end}}
+    </div>
+
+    {{if and (not .User) .APEnabled}}
+    <div class="participate-panel" data-panel="mastodon" hidden>
+        <p class="participate-hint">Follow this repository from Mastodon (or any ActivityPub app) to see new threads
+            and replies in your feed. Reply there — to the thread or to any specific message — and your comment
+            appears here automatically, no fierj account needed.</p>
+        <div class="fediverse-handle">
+            <span class="fediverse-icon" aria-hidden="true">🐘</span>
+            <code>@{{.Repo}}@{{.Host}}</code>
+        </div>
+        <form class="remote-follow-form" data-actor-url="{{.ActorURL}}" onsubmit="return fjRemoteFollow(this)">
+            <input type="text" name="instance" placeholder="your-instance.social"
+                aria-label="Your Mastodon instance domain">
+            <button type="submit" class="btn btn-subtle">Follow from my instance</button>
+        </form>
+    </div>
+    {{end}}
+
+    {{if not .User}}
+    <div class="participate-panel" data-panel="email" hidden>
+        <p class="participate-hint">Get a one-time link by email so you can comment without creating an account.
+            <span class="badge-soon">Coming soon</span>
+        </p>
+        <div class="email-magic-form">
+            <input type="email" placeholder="you@example.com" disabled>
+            <button type="button" class="btn btn-subtle" disabled>Email me a link</button>
         </div>
-    </form>
+    </div>
+    {{end}}
 </div>
-{{template "foot" .}}
 
+<script>
+    function fjSetReplyTarget(id, author) {
+        document.getElementById('parent_id').value = id;
+        document.getElementById('replying-to-name').textContent = author;
+        document.getElementById('replying-to').classList.add('on');
+        const webTab = document.querySelector('.participate-tab[data-panel="web"]');
+        if (webTab) fjTab(webTab);
+        const ta = document.getElementById('reply-body');
+        ta.scrollIntoView({ behavior: 'smooth', block: 'center' });
+        ta.focus();
+    }
+
+    document.querySelectorAll('.reply-link[data-reply-id]').forEach(btn => {
+        btn.addEventListener('click', () => fjSetReplyTarget(btn.dataset.replyId, btn.dataset.replyAuthor));
+    });
+
+    const cancelBtn = document.getElementById('cancel-target');
+    if (cancelBtn) cancelBtn.addEventListener('click', () => {
+        document.getElementById('parent_id').value = '';
+        document.getElementById('replying-to').classList.remove('on');
+    });
+
+    function fjJumpTo(id) {
+        const el = document.getElementById('reply-' + id) || document.getElementById('reply-root');
+        el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+        el.classList.add('flash');
+        setTimeout(() => el.classList.remove('flash'), 900);
+    }
+
+    document.querySelectorAll('.reply-pointer[data-jump]').forEach(btn => {
+        btn.addEventListener('click', () => fjJumpTo(btn.dataset.jump));
+    });
+</script>
+{{template "foot" .}}
diff --git a/thread.go b/thread.go
index 9e9fe97..d7b6d97 100644
--- a/thread.go
+++ b/thread.go
@@ -36,6 +36,7 @@ type Thread struct {
 
 type Reply struct {
 	ID         string `json:"id"`
+	ParentID   string `json:"parent_id,omitempty"` // ID of parent reply (empty = reply to thread root)
 	Body       string `json:"body"`
 	Author     string `json:"author"`
 	AuthorName string `json:"author_name"`
@@ -76,6 +77,11 @@ func createThread(repoPath, title, body, author, authorName string) (*Thread, er
 }
 
 func addReply(repoPath, threadID, body, author, authorName string) error {
+	return addNestedReply(repoPath, threadID, "", body, author, authorName)
+}
+
+// addNestedReply adds a reply, optionally nesting it under a parent reply.
+func addNestedReply(repoPath, threadID, parentID, body, author, authorName string) error {
 	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
 	unlock := lockThread(threadPath)
 	defer unlock()
@@ -88,6 +94,7 @@ func addReply(repoPath, threadID, body, author, authorName string) error {
 	now := time.Now().UTC().Format(time.RFC3339)
 	t.Replies = append(t.Replies, Reply{
 		ID:         newULID(),
+		ParentID:   parentID,
 		Body:       body,
 		Author:     author,
 		AuthorName: authorName,
@@ -178,6 +185,44 @@ func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
 	return threads, openCount, closedCount
 }
 
+// ReplyView augments a Reply with a rendering hint: whether its reply
+// target needs to be called out to the reader.
+type ReplyView struct {
+	Reply
+	ShowParent   bool
+	ParentAuthor string
+}
+
+// buildReplyViews lays replies out in their natural chronological (storage)
+// order — a thread reads as one linear conversation, not a tree — and marks
+// which ones need a "replying to X" pointer. A reply to the thread root, or
+// to whichever reply immediately precedes it, is the assumed default (that
+// covers a plain web comment and the common "reply to the latest message"
+// case from Mastodon) and stays unannotated. Anything else is a genuine
+// callback into earlier history and gets pointed out.
+func buildReplyViews(replies []Reply) []ReplyView {
+	byID := make(map[string]Reply, len(replies))
+	for _, r := range replies {
+		byID[r.ID] = r
+	}
+	views := make([]ReplyView, len(replies))
+	prevID := ""
+	for i, r := range replies {
+		v := ReplyView{Reply: r}
+		if r.ParentID != "" && r.ParentID != prevID {
+			v.ShowParent = true
+			if p, ok := byID[r.ParentID]; ok {
+				v.ParentAuthor = p.AuthorName
+			} else {
+				v.ParentAuthor = "an earlier message"
+			}
+		}
+		views[i] = v
+		prevID = r.ID
+	}
+	return views
+}
+
 func writeThread(repoPath string, t *Thread) error {
 	dir := threadsDir(repoPath)
 	if err := os.MkdirAll(dir, 0755); err != nil {
diff --git a/thread_handlers.go b/thread_handlers.go
index cc24233..6e623e1 100644
--- a/thread_handlers.go
+++ b/thread_handlers.go
@@ -3,6 +3,7 @@ package main
 import (
 	"fmt"
 	"html/template"
+	"log/slog"
 	"net/http"
 	"path/filepath"
 	"strings"
@@ -38,6 +39,8 @@ func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			"Tags":        git.Tags(),
 			"Commits":     git.CommitCount(ref),
 			"ActiveTab":   "threads",
+			"Host":        cfg.Host,
+			"APEnabled":   cfg.Host != "",
 		}))
 	}
 }
@@ -58,6 +61,8 @@ func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			"Tags":        git.Tags(),
 			"Commits":     git.CommitCount(ref),
 			"ActiveTab":   "threads",
+			"Host":        cfg.Host,
+			"APEnabled":   cfg.Host != "",
 		}))
 	}
 }
@@ -101,17 +106,28 @@ func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
 			return
 		}
 		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
+		apEnabled := cfg.Host != ""
+		actorURLStr := ""
+		if apEnabled {
+			actorURLStr = actorURL(cfg.Host, git.Name)
+		}
+		if err := tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
 			"Repo":        git.Repo(),
 			"IsPrivate":   git.IsPrivate(),
 			"Ref":         ref,
 			"Thread":      t,
+			"ReplyViews":  buildReplyViews(t.Replies),
 			"Description": git.Description(),
 			"Branches":    git.Branches(),
 			"Tags":        git.Tags(),
 			"Commits":     git.CommitCount(ref),
 			"ActiveTab":   "threads",
-		}))
+			"Host":        cfg.Host,
+			"APEnabled":   apEnabled,
+			"ActorURL":    actorURLStr,
+		})); err != nil {
+			slog.Error("thread view template", "error", err)
+		}
 	}
 }
 
@@ -129,20 +145,36 @@ func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		}
 		author, authorName := threadAuthor(r)
 		rp := repoPath(cfg, git.Name)
-		if err := addReply(rp, threadID, body, author, authorName); err != nil {
+		parentID := strings.TrimSpace(r.FormValue("parent_id"))
+		if err := addNestedReply(rp, threadID, parentID, body, author, authorName); err != nil {
 			renderError(w, r, tmpl, http.StatusInternalServerError, err.Error())
 			return
 		}
+		// Deliver reply to ActivityPub followers.
+		t, _ := loadThread(rp, threadID)
+		if t != nil {
+			go deliverReplyCreate(cfg, git.Name, t)
+		}
 		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
 	}
 }
 
+// threadAuthor identifies the actor posting a thread/reply. Logged-in users
+// are attributed by their account name. Anonymous web visitors are always
+// recorded as "anonymous" (no identity is verified), but may attach a
+// free-text display name so their comments are distinguishable in the UI.
 func threadAuthor(r *http.Request) (author, displayName string) {
-	author = User(r)
-	if author == "" {
-		author = "anonymous"
+	if u := User(r); u != "" {
+		return u, u
+	}
+	name := strings.TrimSpace(r.FormValue("guest_name"))
+	if name == "" {
+		return "anonymous", "Anonymous"
+	}
+	if len(name) > 60 {
+		name = name[:60]
 	}
-	return author, author
+	return "anonymous", name
 }
 
 func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {