diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index 9e8dc9b..129d254 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -306,12 +306,13 @@ task documents and git history rather than this list. the followed-community gate, and the announcer could simply not announce the content instead — but there is no per-entity payload to corroborate against, so closing it needs a different mechanism. -- **`materializeContent`'s legacy `origin=bridge` check is now a subset of - the classifier** for announced traffic, and its only unique coverage is - the bare Create/Update branch — the one dispatch branch that - deliberately does not call `suppressEcho`, unlike Delete/Undo. The - asymmetry is undocumented and the legacy check misses ids the classifier - would catch (outbound_objects-only ids, activity ids, our own actor). +- **`materializeContent`'s legacy `origin=bridge` check is now a strict + subset of `suppressEcho`.** Bare content no longer reaches + `materializeContent` (bare Create/Update of content is dropped in + `handleBareCreateUpdate`), so the only path left is the Announce path, + where `suppressEcho` has already run and catches every id the legacy check + would plus ids it misses (outbound_objects-only ids, activity ids, our own + actor). The open question is only whether to delete it. - **`Drops()` is exported with no production caller** and the tests keep a parallel class list; adding a fifth class would silently under-assert every "only this class moved" test. Export the class list instead. diff --git a/README.md b/README.md index 344ca48..6ac2853 100644 --- a/README.md +++ b/README.md @@ -532,10 +532,15 @@ activity by the ingestion layer: `hostedBy` to the bridge's service DID. Deliveries are accepted only over valid draft-cavage HTTP signatures -(rsa-sha256, 1h date-skew window, digest required); content is accepted only -from communities the operator subscribed to, and embedded objects are -re-fetched from their origin instance whenever the delivering signer lacks -authority over the object's id. +(rsa-sha256, 1h date-skew window, digest required). Content (posts, comments, +edits, restores) is materialized only from the subscribed community's own +`Announce`, and embedded objects are re-fetched from their origin instance +whenever the delivering signer lacks authority over the object's id. Bare +`Create`/`Update` of content — including replies sent straight to a persona +inbox and replies with no audience — and bare `Undo{Delete}` restores of +content are dropped as processed skips, counted on +`tidepool_content_bare_dropped`. A bare `Update{Person|Group}` only refreshes +actors already bridged. ## Sync surface (what relays and Jetstream consume) @@ -686,7 +691,7 @@ What the user origin serves: | `GET /.well-known/webfinger?resource=acct:alice@…` | discovery for a minted local part, scoped to the routed `Host` | | `GET /ap/actor/{did}` | the user's `Person` document (`publicKey`, `inbox`, `endpoints.sharedInbox`, `outbox`, `published`) | | `GET /ap/actor/{did}/outbox` | empty `OrderedCollection` — Lemmy requires the field, and a missing outbox rejects the whole actor | -| `POST /ap/inbox` | shared inbox; dispatched **verbatim** to the existing ingest pipeline (one verification, dedupe, and refusal taxonomy — never a second copy) | +| `POST /ap/inbox` | shared inbox; dispatched **verbatim** to the existing ingest pipeline (one verification, dedupe, and refusal taxonomy — never a second copy), so bare content sent here is dropped like any other | | `GET /` | the origin's instance (`Application`) actor, republishing the bridge's key — Lemmy delivers `Delete{Person}` and other send-to-all-instances activities only to the inbox on that row | | `GET /.well-known/nodeinfo`, `GET /nodeinfo/2.0` | software identification (`software.name: tidepool`) | diff --git a/internal/ingest/bare_content_test.go b/internal/ingest/bare_content_test.go new file mode 100644 index 0000000..12450f6 --- /dev/null +++ b/internal/ingest/bare_content_test.go @@ -0,0 +1,441 @@ +package ingest + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/ap" + "tidepool/internal/errors" + "tidepool/internal/materialize" +) + +// TestBareContentDeliveryIsNeverMaterialized: content reaches a bridged +// community only through that community's own Announce. A bare Create/Update +// is signed by whoever sent it, and nothing about that signature says the +// community ever saw the content — yet materializing a post mints a bridged +// actor for its author and writes the community's acceptance on the +// community's behalf. So any host could inject posts and comments the real +// Lemmy community never carried, minting an identity per attempt. A bare +// content delivery is therefore a processed skip, decided before any +// outbound fetch or mint; genuine Lemmy traffic loses nothing, because Lemmy +// announces every piece of community content. +func TestBareContentDeliveryIsNeverMaterialized(t *testing.T) { + const ( + editorID = "https://lemmy.world/u/bareEditor" + postID = "https://lemmy.world/post/bare-edit-1" + strangerID = "https://sopuli.example/u/bareStranger" + replierID = "https://sopuli.example/u/bareReplier" + injectedID = "https://sopuli.example/post/bare-injected-1" + replyID = "https://sopuli.example/comment/bare-reply-1" + ) + + type bareCase struct { + name string + // personaInbox delivers to the user origin's persona inbox instead of + // the bridge's shared inbox. + personaInbox bool + // senderID (username senderUsername) signs the bare delivery. + senderID, senderUsername string + activity map[string]any + // originPaths are served documents the drop must never fetch while + // processing (the signature check at the inbox may fetch the key). + originPaths []string + // served registers the origin documents the row's ids resolve to. + served map[string]map[string]any + check func(t *testing.T, h *harness, group *remoteActor) + } + + injectedPage := map[string]any{ + "type": "Page", + "id": injectedID, + "attributedTo": strangerID, + "to": []any{groupID, ap.PublicAudience}, + "audience": groupID, + "name": "a post lemmy.world never carried", + "source": map[string]any{"content": "injected body", "mediaType": "text/markdown"}, + "published": "2026-07-08T17:00:00.000000Z", + } + editedPage := map[string]any{ + "type": "Page", + "id": postID, + "attributedTo": editorID, + "to": []any{groupID, ap.PublicAudience}, + "audience": groupID, + "name": "edited title", + "source": map[string]any{"content": "edited body", "mediaType": "text/markdown"}, + "published": "2026-07-08T16:00:00.000000Z", + "updated": "2026-07-08T19:00:00.000000Z", + } + reply := note(replyID, replierID, postID, "a reply sent to the persona", "2026-07-08T18:00:00.000000Z") + // A Mastodon-style reply: no audience, so it names no community. + audiencelessReply := map[string]any{ + "type": "Note", + "id": "https://sopuli.example/comment/bare-tooter-reply-1", + "attributedTo": "https://sopuli.example/u/bareTooter", + "to": []any{ap.PublicAudience}, + "cc": []any{editorID}, + "content": "

a reply that names no community

", + "published": "2026-07-08T18:30:00.000000Z", + "inReplyTo": postID, + } + + cases := []bareCase{ + { + name: "bare Create{Page} from an instance the community never announced", + senderID: strangerID, + senderUsername: "bareStranger", + activity: map[string]any{ + "id": "https://sopuli.example/activities/create/bare-injected-1", + "type": "Create", + "actor": strangerID, + "to": []any{groupID, ap.PublicAudience}, + "object": injectedPage, + }, + originPaths: []string{"/u/bareStranger", "/post/bare-injected-1"}, + served: map[string]map[string]any{"/post/bare-injected-1": injectedPage}, + check: func(t *testing.T, h *harness, _ *remoteActor) { + ctx := context.Background() + _, err := h.objects.GetByAPID(ctx, injectedID) + assert.True(t, errors.IsNotFound(err), "a bare post must not be materialized") + _, err = h.actors.GetByAPActorID(ctx, strangerID) + assert.True(t, errors.IsNotFound(err), "a bare post must not bridge its author") + assert.Equal(t, 1, h.firehoseOpCount(materialize.CollectionPostV2), + "only the announced post is in any author repo") + assert.Equal(t, 1, h.firehoseOpCount(materialize.CollectionAcceptance), + "only the announced post is accepted in the community repo") + }, + }, + { + name: "bare Create{Note} reply at a persona inbox, then the same Note announced", + personaInbox: true, + senderID: replierID, + senderUsername: "bareReplier", + activity: map[string]any{ + "id": "https://sopuli.example/activities/create/bare-reply-1", + "type": "Create", + "actor": replierID, + "to": []any{ap.PublicAudience}, + "cc": []any{groupID, editorID}, + "object": reply, + }, + originPaths: []string{"/u/bareReplier", "/comment/bare-reply-1"}, + served: map[string]map[string]any{"/comment/bare-reply-1": reply}, + check: func(t *testing.T, h *harness, group *remoteActor) { + ctx := context.Background() + _, err := h.objects.GetByAPID(ctx, replyID) + assert.True(t, errors.IsNotFound(err), "a bare reply must not be materialized") + _, err = h.actors.GetByAPActorID(ctx, replierID) + assert.True(t, errors.IsNotFound(err), "a bare reply must not bridge its author") + assert.Equal(t, 0, h.firehoseOpCount(materialize.CollectionComment), + "no comment record in any author repo") + + // The community's own fan-out of the same reply still lands: the + // drop loses nothing Lemmy actually carries. + h.announceCreate(group, "https://lemmy.world/activities/announce/create/bare-reply-1", reply) + mapping, err := h.objects.GetByAPID(ctx, replyID) + require.NoError(t, err, "the announced reply must be materialized") + assert.Equal(t, materialize.CollectionComment, mapping.Collection) + assert.Equal(t, testDIDFor("bareReplier", "sopuli.example"), mapping.DID) + }, + }, + { + // A Mastodon-style reply names no community, so nothing past the + // bare-content drop would reject it. + name: "bare Create{Note} reply with no audience at a persona inbox", + personaInbox: true, + senderID: "https://sopuli.example/u/bareTooter", + senderUsername: "bareTooter", + activity: map[string]any{ + "id": "https://sopuli.example/activities/create/bare-tooter-reply-1", + "type": "Create", + "actor": "https://sopuli.example/u/bareTooter", + "to": []any{ap.PublicAudience}, + "cc": []any{editorID}, + "object": audiencelessReply, + }, + originPaths: []string{"/u/bareTooter", "/comment/bare-tooter-reply-1"}, + served: map[string]map[string]any{"/comment/bare-tooter-reply-1": audiencelessReply}, + check: func(t *testing.T, h *harness, _ *remoteActor) { + ctx := context.Background() + _, err := h.objects.GetByAPID(ctx, "https://sopuli.example/comment/bare-tooter-reply-1") + assert.True(t, errors.IsNotFound(err), "a bare reply with no audience must not be materialized") + _, err = h.actors.GetByAPActorID(ctx, "https://sopuli.example/u/bareTooter") + assert.True(t, errors.IsNotFound(err), "a bare reply with no audience must not bridge its author") + assert.Equal(t, 0, h.firehoseOpCount(materialize.CollectionComment), + "no comment record in any author repo") + }, + }, + { + name: "bare Update{Page} from the genuine author of an announced post", + senderID: editorID, + senderUsername: "bareEditor", + activity: map[string]any{ + "id": "https://lemmy.world/activities/update/bare-edit-1", + "type": "Update", + "actor": editorID, + "to": []any{groupID, ap.PublicAudience}, + "object": editedPage, + }, + // The origin serves the edited body, so a regression that re-fetches + // the bare object shows up as a hit here. + originPaths: []string{"/u/bareEditor", "/post/bare-edit-1"}, + served: map[string]map[string]any{"/post/bare-edit-1": editedPage}, + check: func(t *testing.T, h *harness, _ *remoteActor) { + ctx := context.Background() + mapping, err := h.objects.GetByAPID(ctx, postID) + require.NoError(t, err) + record, _, err := h.manager.GetRecord(ctx, mapping.DID, mapping.Collection, mapping.RKey) + require.NoError(t, err) + assert.Equal(t, "original title", record["title"], "a bare edit must not be applied") + assert.Equal(t, "original body", record["content"], "a bare edit must not be applied") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + group := h.subscribeTechnology() + ctx := context.Background() + + // The community's own post, by a lemmy.world author who can sign: + // the reply's parent and the edit's target. + editor := h.newRemoteActor(editorID, person(editorID, "bareEditor", nil)) + h.announceCreate(group, "https://lemmy.world/activities/announce/create/bare-edit-1", map[string]any{ + "type": "Page", + "id": postID, + "attributedTo": editorID, + "to": []any{groupID, ap.PublicAudience}, + "audience": groupID, + "name": "original title", + "source": map[string]any{"content": "original body", "mediaType": "text/markdown"}, + "published": "2026-07-08T16:00:00.000000Z", + }) + _, err := h.objects.GetByAPID(ctx, postID) + require.NoError(t, err, "the announced post is the premise") + + // Every sender's actor document is servable, so a refusal is the + // drop and not a failed fetch. + sender := editor + if tc.senderID != editorID { + sender = h.newRemoteActor(tc.senderID, person(tc.senderID, tc.senderUsername, nil)) + } + for path, doc := range tc.served { + h.serveObject(path, doc) + } + + activityID, ok := tc.activity["id"].(string) + require.True(t, ok, "every row's activity carries a string id") + deliver := h.deliver + if tc.personaInbox { + deliver = h.deliverToUserInbox + } + droppedBefore := ContentBareDropped.Value() + require.Equal(t, http.StatusAccepted, deliver(sender, tc.activity)) + mintsBefore := h.minter.mintCount() + hitsBefore := map[string]int{} + for _, path := range tc.originPaths { + hitsBefore[path] = h.hitCount(path) + } + h.drain() + + event, err := h.events.GetEvent(ctx, activityID) + require.NoError(t, err) + assert.NotNil(t, event.ProcessedAt, "the drop is a processed skip") + assert.Nil(t, event.FailedAt, "the drop is never poisoned") + assert.Equal(t, 1, event.Attempts, "the drop is never retried") + assert.Equal(t, int64(1), ContentBareDropped.Value()-droppedBefore, + "tidepool_content_bare_dropped rises once per dropped bare content delivery") + assert.Equal(t, mintsBefore, h.minter.mintCount(), "a bare content delivery must never mint") + for _, path := range tc.originPaths { + assert.Equal(t, hitsBefore[path], h.hitCount(path), + "a bare content delivery must not fetch %s", path) + } + + tc.check(t, h, group) + }) + } +} + +// TestBareUndoDeleteNeverRematerializesContent: an Undo{Delete} of mapped +// content re-fetches and re-materializes it — a fetch, a record write, and +// possibly a mint — so it is content arriving, and content arrives only +// through its community's Announce. Lemmy sends a restore as +// Announce{Undo{Delete}}, so a bare Undo{Delete} of any mapped post or comment, +// soft-deleted or live, is a processed skip decided before any fetch: a +// deleted target stays deleted, and a live target keeps the record it has even +// when its origin now serves an edited body. The community's announced restore +// of a deleted target still brings it back. +func TestBareUndoDeleteNeverRematerializesContent(t *testing.T) { + const ( + authorID = "https://lemmy.world/u/restoreAuthor" + postID = "https://lemmy.world/post/bare-restore-1" + commentID = "https://lemmy.world/comment/bare-restore-1" + ) + page := map[string]any{ + "type": "Page", + "id": postID, + "attributedTo": authorID, + "to": []any{groupID, ap.PublicAudience}, + "audience": groupID, + "name": "a post by restoreAuthor", + "source": map[string]any{"content": "original post body", "mediaType": "text/markdown"}, + "published": "2026-07-08T17:00:00.000000Z", + } + editedPage := map[string]any{ + "type": "Page", + "id": postID, + "attributedTo": authorID, + "to": []any{groupID, ap.PublicAudience}, + "audience": groupID, + "name": "a post by restoreAuthor", + "source": map[string]any{"content": "edited post body", "mediaType": "text/markdown"}, + "published": "2026-07-08T17:00:00.000000Z", + "updated": "2026-07-08T19:00:00.000000Z", + } + comment := note(commentID, authorID, postID, "original comment body", "2026-07-08T17:30:00.000000Z") + + rows := []struct { + name string + targetID string + targetPath string + collection string + // live leaves the target mapped and undeleted, with its origin serving + // an edited body; otherwise the author deletes it through the community + // first. + live bool + // wantMarkers are the target's tombstone rows before and after the drop. + wantMarkers []string + // wantContent is the record's content: the live record kept as it is, + // or the deleted record once the community's announced restore lands. + wantContent string + }{ + { + name: "soft-deleted post", + targetID: postID, + targetPath: "/post/bare-restore-1", + collection: materialize.CollectionPostV2, + wantMarkers: []string{groupID}, + wantContent: "original post body", + }, + { + name: "soft-deleted comment", + targetID: commentID, + targetPath: "/comment/bare-restore-1", + collection: materialize.CollectionComment, + wantMarkers: []string{groupID}, + wantContent: "original comment body", + }, + { + name: "live post whose origin now serves an edit", + targetID: postID, + targetPath: "/post/bare-restore-1", + collection: materialize.CollectionPostV2, + live: true, + wantMarkers: []string{}, + wantContent: "original post body", + }, + } + + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + h := newHarness(t) + group := h.subscribeTechnology() + ctx := context.Background() + author := h.newRemoteActor(authorID, person(authorID, "restoreAuthor", nil)) + // The origin keeps serving both objects, so a restore that fetched + // would succeed: a refusal below is the drop, not a failed fetch. + h.serveObject("/post/bare-restore-1", page) + h.serveObject("/comment/bare-restore-1", comment) + h.announceCreate(group, "https://lemmy.world/activities/announce/create/bare-restore-post", page) + h.announceCreate(group, "https://lemmy.world/activities/announce/create/bare-restore-comment", comment) + + if row.live { + h.serveObject("/post/bare-restore-1", editedPage) + } else { + h.announceDelete(group, "https://lemmy.world/activities/announce/delete/bare-restore-1", + authorID, row.targetID) + } + premise, err := h.objects.GetByAPID(ctx, row.targetID) + require.NoError(t, err) + require.Equal(t, !row.live, premise.IsDeleted(), "the target's deleted state is the premise") + require.Equal(t, row.collection, premise.Collection) + require.Equal(t, row.wantMarkers, h.tombstoneAnnouncers(row.targetID), + "the target's tombstone rows are the premise") + + const undoID = "https://lemmy.world/activities/undo/bare-restore-1" + deleteActivity := map[string]any{ + "id": "https://lemmy.world/activities/delete/bare-restore-1", + "type": "Delete", + "actor": authorID, + "object": row.targetID, + } + droppedBefore := ContentBareDropped.Value() + require.Equal(t, http.StatusAccepted, h.deliver(author, map[string]any{ + "id": undoID, + "type": "Undo", + "actor": authorID, + "object": deleteActivity, + })) + mintsBefore := h.minter.mintCount() + hitsBefore := h.hitCount(row.targetPath) + opsBefore := len(h.firehoseOps()) + h.drain() + + event, err := h.events.GetEvent(ctx, undoID) + require.NoError(t, err) + assert.NotNil(t, event.ProcessedAt, "the drop is a processed skip") + assert.Nil(t, event.FailedAt, "the drop is never poisoned") + assert.Equal(t, 1, event.Attempts, "the drop is never retried") + assert.Equal(t, int64(1), ContentBareDropped.Value()-droppedBefore, + "tidepool_content_bare_dropped rises once for the dropped bare restore") + assert.Equal(t, hitsBefore, h.hitCount(row.targetPath), "a bare restore must not fetch its target") + assert.Equal(t, mintsBefore, h.minter.mintCount(), "a bare restore must never mint") + assert.Equal(t, opsBefore, len(h.firehoseOps()), "a bare restore must write no record") + assert.Equal(t, row.wantMarkers, h.tombstoneAnnouncers(row.targetID), + "a bare restore must leave the target's tombstone rows as they were") + mapping, err := h.objects.GetByAPID(ctx, row.targetID) + require.NoError(t, err) + if row.live { + assert.False(t, mapping.IsDeleted(), "a bare restore must not delete a live mapping") + record, _, err := h.manager.GetRecord(ctx, mapping.DID, mapping.Collection, mapping.RKey) + require.NoError(t, err) + assert.Equal(t, row.wantContent, record["content"], + "a bare restore must not apply the origin's edited body") + return + } + assert.True(t, mapping.IsDeleted(), "a bare restore must not revive the mapping") + _, _, err = h.manager.GetRecord(ctx, mapping.DID, mapping.Collection, mapping.RKey) + assert.True(t, errors.IsNotFound(err), "a bare restore must not rewrite the record (err=%v)", err) + + // The community's own restore of the same target still lands. + require.Equal(t, http.StatusAccepted, h.deliver(group, map[string]any{ + "id": "https://lemmy.world/activities/announce/undo/bare-restore-1", + "type": "Announce", + "actor": groupID, + "audience": groupID, + "object": map[string]any{ + "id": undoID, + "type": "Undo", + "actor": authorID, + "audience": groupID, + "object": deleteActivity, + }, + })) + h.drain() + + restored, err := h.objects.GetByAPID(ctx, row.targetID) + require.NoError(t, err) + assert.False(t, restored.IsDeleted(), "the announced restore revives the mapping") + record, _, err := h.manager.GetRecord(ctx, restored.DID, restored.Collection, restored.RKey) + require.NoError(t, err, "the announced restore rewrites the record") + assert.Equal(t, row.wantContent, record["content"]) + assert.Equal(t, int64(1), ContentBareDropped.Value()-droppedBefore, + "the announced restore is not a bare drop") + }) + } +} diff --git a/internal/ingest/consent.go b/internal/ingest/consent.go index 7da74e7..ad8639d 100644 --- a/internal/ingest/consent.go +++ b/internal/ingest/consent.go @@ -370,12 +370,9 @@ func (h *Handler) handleUndo(ctx context.Context, undo *ap.Object, signer string // Idempotent throughout; a restore for content that is still gone upstream is // a skip. // -// Deliberate, operator-visible policy: a BARE restore of mapped content -// overrides a community's moderation delete of that content. It is bounded by -// the same-authority signer, an existing mapping, a pinned re-fetch and the -// type check — i.e. an origin re-serving content it previously bridged — and -// the origin re-serving an object is the strongest statement anyone makes -// about it, which is what the bridge mirrors. +// A BARE restore of mapped content is dropped before the fetch: the +// re-materialization is content arriving, and content arrives only through its +// community's Announce (Lemmy sends a restore as Announce{Undo{Delete}}). func (h *Handler) handleUndoDelete(ctx context.Context, undo, del *ap.Object, signer string, announcer *store.Community) error { targetID := refID(del.Object) if targetID == "" { @@ -415,6 +412,14 @@ func (h *Handler) handleUndoDelete(ctx context.Context, undo, del *ap.Object, si return h.restoreNativeContent(ctx, undo, mapping, announcer, scope) } + if announcer == nil { + switch mapping.Collection { + case materialize.CollectionPost, materialize.CollectionPostV2, materialize.CollectionComment: + return h.dropBareContent(undo, del.Object, signer, + "bare restore of content is not applied: content is restored only from its community's Announce") + } + } + // Pinned to the target's own authority: this fetch's answer is what // authorizes the restore AND what gets written into the repo, so an open // redirect on the origin must fail it rather than both license the restore @@ -473,9 +478,7 @@ func (h *Handler) handleUndoDelete(ctx context.Context, undo, del *ap.Object, si return fmt.Errorf("ingest: re-soft-delete after failed restore of %s: %w", targetID, rerr) } // Same scope the delete would have used: the marker this authorization - // context is entitled to lay. A bare undo that cleared other communities' - // markers does not re-create them — it got here on the target id's own - // authority, which outranks their claim regardless of how this ends. + // context is entitled to lay. if rerr := h.tombstones.Record(ctx, targetID, scope); rerr != nil { return fmt.Errorf("ingest: re-record tombstone after failed restore of %s: %w", targetID, rerr) } @@ -497,15 +500,8 @@ func (h *Handler) handleUndoDelete(ctx context.Context, undo, del *ap.Object, si // make it: an ANNOUNCED undo of a delete that carried a summary — the same // pair of signals that produced the removal in the first place. // - // A BARE undo deliberately does not qualify. That path exists so an ORIGIN - // can un-delete content it re-serves, and it is permissive by design - // (same-authority signer, pinned re-fetch). None of that says anything - // about a community's decision to remove the post from itself, and a fresh - // acceptance IS a restore — so honouring it would let an author's own - // instance overturn moderation by re-serving the post. The bare path still - // restores the RECORD and its mapping; the acceptance stays withheld by - // the terminality guard in acceptPost, which leaves the post present but - // invisible in that community until a moderator restores it. + // A BARE undo never gets here: a bare restore of content is dropped before + // the fetch. if mapping.Collection == materialize.CollectionPostV2 && announcer != nil && del.HasSummary() { if err := h.mat.RestorePost(ctx, mapping); err != nil { return err diff --git a/internal/ingest/delegation_arrival_test.go b/internal/ingest/delegation_arrival_test.go index 854da4b..95073f9 100644 --- a/internal/ingest/delegation_arrival_test.go +++ b/internal/ingest/delegation_arrival_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "tidepool/internal/ap" + "tidepool/internal/errors" "tidepool/internal/store" ) @@ -23,6 +24,10 @@ const ( // fakeMinter mints ..bridge.test, so every // lemmy.world actor in the harness lands on this label. arrivalLabel = "lemmy-world" + // arrivalPostURI is where arrivalPage materializes: the fake minter's DID + // for arrivalauthor@lemmy.world and the rkey derived from the page's id and + // published time. + arrivalPostURI = "at://did:plc:56demzzr6cdn4afwayil4xsa/social.coves.community.postv2/3mq5ls44gerpn" ) func arrivalPage() map[string]any { @@ -103,9 +108,9 @@ func TestBareCreateDoesNotCountTowardDelegation(t *testing.T) { h.deliverBareCreate(author, "https://lemmy.world/activities/create/arrival-bare", arrivalPage()) - // Ingest is unchanged: the bare Create is still materialized. - h.mappedATURI(arrivalPostID) - assert.Equal(t, "not_announced", h.arrival(arrivalPostID)) + // A bare Create is dropped, so there is no object to count. + _, err := h.objects.GetByAPID(context.Background(), arrivalPostID) + assert.True(t, errors.IsNotFound(err), "a bare Create must never be materialized") assert.Empty(t, h.labelContributionURIs()) } @@ -115,19 +120,21 @@ func TestBareThenAnnouncedPostCountsTowardDelegation(t *testing.T) { author := h.newRemoteActor(arrivalAuthorID, person(arrivalAuthorID, "arrivalauthor", nil)) h.deliverBareCreate(author, "https://lemmy.world/activities/create/arrival-first", arrivalPage()) + _, err := h.objects.GetByAPID(context.Background(), arrivalPostID) + require.True(t, errors.IsNotFound(err), "the bare delivery is dropped: no mapping") require.Empty(t, h.labelContributionURIs(), "the bare copy alone must not count") - // The Announce re-materializes an already-mapped object as a no-op; the - // community's Announce still marks it. + // The community's Announce materializes the post and marks it. h.announceCreate(group, "https://lemmy.world/activities/announce/arrival-second", arrivalPage()) - postURI := h.mappedATURI(arrivalPostID) + assert.Equal(t, arrivalPostURI, h.mappedATURI(arrivalPostID)) assert.Equal(t, "community_announced", h.arrival(arrivalPostID)) - assert.Equal(t, []string{postURI}, h.labelContributionURIs()) + assert.Equal(t, []string{arrivalPostURI}, h.labelContributionURIs()) - // The mark is one-way: a later bare delivery does not downgrade it. + // A later bare delivery of the same object changes nothing. h.deliverBareCreate(author, "https://lemmy.world/activities/create/arrival-third", arrivalPage()) + assert.Equal(t, arrivalPostURI, h.mappedATURI(arrivalPostID)) assert.Equal(t, "community_announced", h.arrival(arrivalPostID)) - assert.Equal(t, []string{postURI}, h.labelContributionURIs()) + assert.Equal(t, []string{arrivalPostURI}, h.labelContributionURIs()) } func TestAnnouncedCommentDoesNotMarkFetchedAncestors(t *testing.T) { diff --git a/internal/ingest/forged_attribution_test.go b/internal/ingest/forged_attribution_test.go index a889047..24d357c 100644 --- a/internal/ingest/forged_attribution_test.go +++ b/internal/ingest/forged_attribution_test.go @@ -18,12 +18,18 @@ import ( // AUTHOR's repo and is signed by that repo's key, so an accepted forgery here // is a signed post the victim never wrote. The delivering instance may only // ever attribute content to its own users — which is exactly what Lemmy's -// verify_domains_match already guarantees of genuine traffic. +// verify_domains_match already guarantees of genuine traffic. A bare Create +// never reaches the materializer's same-authority author check +// (requireSameAuthorityAuthor; covered directly in +// internal/materialize/forged_attribution_test.go): bare content is dropped +// outright, before any fetch or mint, and content is materialized only from +// its community's Announce. This pins that a forged attributedTo delivered +// bare is never materialized and never bridges the victim. func TestBareCreateCannotAttributeToAnotherInstance(t *testing.T) { h := newHarness(t) h.subscribeTechnology() - // The victim's actor document is fetchable, so the refusal below is the - // attribution check and not a failed mint. + // The victim's actor document is fetchable, so a refusal is a deliberate + // drop and not a failed mint. h.serveLemmyWorldContent() ctx := context.Background() diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index 03e8bd7..9ae0914 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -325,6 +325,25 @@ func (h *Handler) dropBareVote(activity, vote *ap.Object, signer, reason string) return skip(activity.ID, reason) } +// ContentBareDropped counts bare (not Announce-wrapped) Create/Update of +// content and bare Undo{Delete} restores of mapped content, dropped as +// processed skips: content is materialized only from its community's +// Announce. A DECIDED non-action, so it is counted: a flat zero must not be +// readable as "this never happens". +var ContentBareDropped = expvar.NewInt("tidepool_content_bare_dropped") + +// dropBareContent counts, logs and skips a bare delivery of content or a bare +// restore of it. activity is what was delivered; obj is the delivered object +// (never fetched), whose own claimed audience is logged. +func (h *Handler) dropBareContent(activity, obj *ap.Object, signer, reason string) error { + ContentBareDropped.Add(1) + h.logger.Info("dropping bare content", + "activity", activity.ID, "signer", signer, + "object", obj.ID, "object_type", obj.Type, + "audience", communityIRIFrom(obj)) + return skip(activity.ID, reason) +} + // handleAnnounce unwraps FEP-1b12 group fan-out: Announce{Create|Update| // Delete|Undo|Like|Dislike|...} from a community we follow. func (h *Handler) handleAnnounce(ctx context.Context, announce *ap.Object, signer string) error { @@ -380,12 +399,12 @@ func (h *Handler) handleAnnounce(ctx context.Context, announce *ap.Object, signe switch inner.Type { case ap.TypeCreate: - return h.materializeContent(ctx, inner.Object, signer, false, signer) + return h.materializeContent(ctx, inner.Object, signer, false) case ap.TypeUpdate: - return h.materializeContent(ctx, inner.Object, signer, true, signer) + return h.materializeContent(ctx, inner.Object, signer, true) case ap.TypePage, ap.TypeArticle, ap.TypeNote: // Some implementations announce the object itself, not the Create. - return h.materializeContent(ctx, inner, signer, false, signer) + return h.materializeContent(ctx, inner, signer, false) case ap.TypeLike, ap.TypeDislike: return h.votes.ApplyVote(ctx, inner, signer) case ap.TypeDelete: @@ -449,27 +468,48 @@ func (h *Handler) suppressEcho(ctx context.Context, activityID string, envelope } // handleBareCreateUpdate processes a Create/Update delivered directly by a -// user (or community) actor rather than through group fan-out. +// user (or community) actor rather than through group fan-out. A bare Create or +// Update of a Person or Group goes to the refresh-only profile path (see +// applyProfileUpdate); everything else is dropped. func (h *Handler) handleBareCreateUpdate(ctx context.Context, activity *ap.Object, signer string) error { obj := activity.Object if obj == nil || (obj.ID == "" && obj.Type == "") { return errors.NewValidationError(activity.Type, "activity carries no object") } - isUpdate := activity.Type == ap.TypeUpdate - return h.materializeContent(ctx, obj, signer, isUpdate, "") + // Content is materialized only from the community's own Announce. The inbox + // accepts any signed host, and a bare delivery's audience is whatever the + // sender wrote: materializing it would mint the author and write the + // community's acceptance for content the community never carried. Lemmy + // (verified) always fans community content out through the community's + // Announce, including its direct copy of a reply to the parent's author; + // PieFed and Mbin are assumed to follow the same FEP-1b12 fan-out. So the + // drop loses no genuine traffic. The drop costs a + // bare delivery no fetch, tombstone work or mint. A bare reference (no + // type) counts as content here, and actor types other than Person and Group + // (Service, Application) are dropped too. + if obj.Type != ap.TypePerson && obj.Type != ap.TypeGroup { + return h.dropBareContent(activity, obj, signer, + "bare delivery of a non-profile object; content is materialized only from its community's Announce") + } + if obj.ID == "" { + return errors.NewValidationError("object", "profile object carries no id") + } + return h.applyProfileUpdate(ctx, obj, signer, "") } -// materializeContent is the single content funnel: echo suppression, -// create-after-delete tombstones, embedded-object trust, followed-community -// checks, then the materializer. announcer is the announcing community's AP -// id ("" when the activity arrived bare). -func (h *Handler) materializeContent(ctx context.Context, obj *ap.Object, signer string, isUpdate bool, announcer string) error { +// materializeContent is the single content funnel for announced objects: echo +// suppression, create-after-delete tombstones, embedded-object trust, the +// announcer-owns-the-content check, then the materializer. It is reached only +// from the Announce path, so the signer is always the announcing community. +func (h *Handler) materializeContent(ctx context.Context, obj *ap.Object, signer string, isUpdate bool) error { if obj == nil || obj.ID == "" { return errors.NewValidationError("object", "content object carries no id") } + // The signer of an Announce is the community that announced it. + announcer := signer - // Profile updates ride the same rails (Announce{Update{Group}}, bare - // Update{Person}) but have their own trust rule; nothing below applies. + // An announced profile update (Announce{Update{Person|Group}}) rides the + // same rails but has its own trust rule; nothing below applies. if obj.Type == ap.TypePerson || obj.Type == ap.TypeGroup { return h.applyProfileUpdate(ctx, obj, signer, announcer) } @@ -491,17 +531,10 @@ func (h *Handler) materializeContent(ctx context.Context, obj *ap.Object, signer // // Markers are scoped to whoever laid them, so the lookup needs this // delivery's community context — and that context may only come from - // somewhere the DELIVERY cannot choose. Announced: the announcer, which - // the inbox bound to the HTTP signature, so a community's own marker - // suppresses its own re-announce right here, before any outbound fetch. - // Bare: nothing trustworthy names a community yet — the only candidate is - // the delivered body's audience, and a Create carrying a bare reference - // ({"id": X} with no type and no audience) names none at all, which would - // read straight past the community-scoped marker that a delete-before- - // create left for exactly this id. So the early check is global-only - // (still free, and a globally tombstoned id costs no fetch), and the - // community-scoped half runs below against the body resolveDelivered - // actually vouches for. + // somewhere the DELIVERY cannot choose: the announcer, which the inbox + // bound to the HTTP signature, so a community's own marker suppresses its + // own re-announce right here, before any outbound fetch. (Bare content + // never gets this far; handleBareCreateUpdate drops it.) tombstoned, err := h.tombstones.ExistsFor(ctx, obj.ID, announcer) if err != nil { return fmt.Errorf("ingest: tombstone check for %s: %w", obj.ID, err) @@ -515,50 +548,19 @@ func (h *Handler) materializeContent(ctx context.Context, obj *ap.Object, signer return err } - // Bare deliveries must belong to a community the bridge follows; the - // announce path already established that for its signer. - if announcer == "" { - communityIRI := communityIRIFrom(obj) - if communityIRI == "" { - return skip(obj.ID, "bare delivery names no community (no audience group IRI)") - } - // The community-scoped half of the create-after-delete check, deferred - // from above: this audience comes from a body the origin served (or one - // the signer vouched for on its own authority), not from a reference the - // deliverer wrote, so a marker laid by the community this object claims - // to belong to now applies to it. - tombstoned, err := h.tombstones.ExistsFor(ctx, obj.ID, communityIRI) - if err != nil { - return fmt.Errorf("ingest: tombstone check for %s: %w", obj.ID, err) - } - if tombstoned { - return skip(obj.ID, "object was deleted upstream before it was ever materialized") - } - community, err := h.communities.GetByAPGroupID(ctx, communityIRI) - if errors.IsNotFound(err) { - return skip(obj.ID, "bare delivery for a community we do not follow: "+communityIRI) - } - if err != nil { - return fmt.Errorf("ingest: look up community %s: %w", communityIRI, err) - } - if community.FollowState == store.FollowStateNone { - return skip(obj.ID, "bare delivery for unfollowed community "+communityIRI) - } - } else { - // Announced content must belong to the announcing community itself: a - // followed community may fan out only its own content, never claim - // another community's (even one co-hosted on the same instance). Since - // the flip the consequence is not a foreign write into a community repo - // — a postv2 goes to its author's repo — but a false BINDING: the - // materializer derives the target community from the object's own - // audience, EnsureCommunity()s it, records it as the mapping's - // community_did and writes that community's acceptance. Without this - // guard an announcer could name any community it likes and hand it both - // visibility over the post and moderation authority over it. - if objCommunity := communityIRIFrom(obj); objCommunity != "" && objCommunity != announcer { - return skip(obj.ID, fmt.Sprintf( - "announced object names community %s but was announced by %s", objCommunity, announcer)) - } + // Announced content must belong to the announcing community itself: a + // followed community may fan out only its own content, never claim + // another community's (even one co-hosted on the same instance). Since + // the flip the consequence is not a foreign write into a community repo + // — a postv2 goes to its author's repo — but a false BINDING: the + // materializer derives the target community from the object's own + // audience, EnsureCommunity()s it, records it as the mapping's + // community_did and writes that community's acceptance. Without this + // guard an announcer could name any community it likes and hand it both + // visibility over the post and moderation authority over it. + if objCommunity := communityIRIFrom(obj); objCommunity != "" && objCommunity != announcer { + return skip(obj.ID, fmt.Sprintf( + "announced object names community %s but was announced by %s", objCommunity, announcer)) } switch obj.Type { @@ -577,15 +579,14 @@ func (h *Handler) materializeContent(ctx context.Context, obj *ap.Object, signer default: return skip(obj.ID, "unsupported content type "+obj.Type) } - if err != nil || announcer == "" { + if err != nil { return err } // Only the community's own Announce makes an object count toward the // delegation bar. The mark goes on after the object is mapped and on the - // announced object alone, never its fetched ancestors. An object that first - // arrived bare is re-materialized here as a no-op and still gets the mark, - // so it counts from the moment the community announces it; a later bare - // delivery never clears it. + // announced object alone, never its fetched ancestors. An object first + // mapped as a fetched ancestor is re-materialized here as a no-op when its + // community announces it, and gets the mark then. if err := h.objects.MarkCommunityAnnounced(ctx, obj.ID); err != nil { return fmt.Errorf("ingest: mark %s community announced: %w", obj.ID, err) } diff --git a/internal/ingest/handler_test.go b/internal/ingest/handler_test.go index edfed4e..e4a23a1 100644 --- a/internal/ingest/handler_test.go +++ b/internal/ingest/handler_test.go @@ -1051,27 +1051,29 @@ func TestAnnouncedObjectForDifferentCommunityDropped(t *testing.T) { // TestUndoDeleteRollsBackWhenRematerializeSkips (Finding 4): if the restore's // re-materialization is declined (a skip), the compensation must re-soft-delete // the mapping and re-record the tombstone — never leave a live mapping without -// a record. +// a record. The restore is the community's Announce{Undo{Delete}}: a bare +// Undo{Delete} of mapped content is dropped before it gets this far +// (TestBareUndoDeleteNeverRematerializesContent). func TestUndoDeleteRollsBackWhenRematerializeSkips(t *testing.T) { h := newHarness(t) group := h.subscribeTechnology() h.serveLemmyWorldContent() - author := h.newRemoteActor(personID, person(personID, "LeftLeaningFreedomFighters", nil)) + h.newRemoteActor(personID, person(personID, "LeftLeaningFreedomFighters", nil)) ctx := context.Background() postID := "https://lemmy.world/post/70001" - buildPage := func(withCommunity bool) map[string]any { + buildPage := func(withAuthor bool) map[string]any { p := map[string]any{ - "type": "Page", - "id": postID, - "attributedTo": personID, - "to": []any{ap.PublicAudience}, - "name": "a post", - "source": map[string]any{"content": "body", "mediaType": "text/markdown"}, - "published": "2026-07-07T08:00:00.000000Z", + "type": "Page", + "id": postID, + "to": []any{ap.PublicAudience}, + "audience": groupID, + "name": "a post", + "source": map[string]any{"content": "body", "mediaType": "text/markdown"}, + "published": "2026-07-07T08:00:00.000000Z", } - if withCommunity { - p["audience"] = groupID + if withAuthor { + p["attributedTo"] = personID } return p } @@ -1095,34 +1097,34 @@ func TestUndoDeleteRollsBackWhenRematerializeSkips(t *testing.T) { require.NoError(t, err) require.False(t, mapping.IsDeleted()) - // Delete it (bare, on the author's own authority). - require.Equal(t, http.StatusAccepted, h.deliver(author, map[string]any{ - "id": "https://lemmy.world/activities/delete/70001", - "type": "Delete", - "actor": personID, - "object": postID, - })) - h.drain() + // The author deletes it through the community. + h.announceDelete(group, "https://lemmy.world/activities/announce/delete/70001", personID, postID) mapping, err = h.objects.GetByAPID(ctx, postID) require.NoError(t, err) require.True(t, mapping.IsDeleted()) - tombstoned, err := h.tombstones.ExistsFor(ctx, postID, "") - require.NoError(t, err) - require.True(t, tombstoned) + require.Equal(t, []string{groupID}, h.tombstoneAnnouncers(postID)) - // The origin re-serves the post but now WITHOUT a community, so - // re-materialization skips ("post names no community"). + // The origin re-serves the post, still in the community, but now WITHOUT + // an author, so re-materialization skips ("post has no attributedTo + // author"). h.serveObject("/post/70001", buildPage(false)) - require.Equal(t, http.StatusAccepted, h.deliver(author, map[string]any{ - "id": "https://lemmy.world/activities/undo/70001", - "type": "Undo", - "actor": personID, + require.Equal(t, http.StatusAccepted, h.deliver(group, map[string]any{ + "id": "https://lemmy.world/activities/announce/undo/70001", + "type": "Announce", + "actor": groupID, + "audience": groupID, "object": map[string]any{ - "id": "https://lemmy.world/activities/delete/70001", - "type": "Delete", - "actor": personID, - "object": postID, + "id": "https://lemmy.world/activities/undo/70001", + "type": "Undo", + "actor": personID, + "audience": groupID, + "object": map[string]any{ + "id": "https://lemmy.world/activities/delete/70001", + "type": "Delete", + "actor": personID, + "object": postID, + }, }, })) h.drain() @@ -1130,9 +1132,8 @@ func TestUndoDeleteRollsBackWhenRematerializeSkips(t *testing.T) { mapping, err = h.objects.GetByAPID(ctx, postID) require.NoError(t, err) assert.True(t, mapping.IsDeleted(), "a declined restore must re-soft-delete the mapping") - tombstoned, err = h.tombstones.ExistsFor(ctx, postID, "") - require.NoError(t, err) - assert.True(t, tombstoned, "a declined restore must retain the tombstone") + assert.Equal(t, []string{groupID}, h.tombstoneAnnouncers(postID), + "a declined restore must re-record the community's tombstone") } // TestLateAcceptAfterUnfollowIgnored (Finding 5): after an operator @@ -1438,17 +1439,15 @@ func TestAnnouncedUndoDeleteIntoAnotherCommunityDropped(t *testing.T) { assert.True(t, errors.IsNotFound(err), "the other community must not be bridged") } -// TestBareReferenceCreateCannotDodgeScopedTombstone pins WHERE the -// create-after-delete check may read its community scope from. Markers are -// community-scoped, so the lookup needs a community context — and before the -// object is resolved a BARE delivery offers only one: the delivered body's -// own audience, which the deliverer wrote. A Create carrying nothing but -// {"id": X} names no community at all, so a lookup keyed off that body reads -// global markers only and sails straight past the community-scoped marker a -// delete-before-create left for exactly that id — from ANY signer with a -// valid signature, for content that is not theirs. That is the marker's core -// case, so the scoped half of the check runs after resolveDelivered, against -// the audience the ORIGIN serves. +// TestBareReferenceCreateCannotDodgeScopedTombstone: a bare Create carrying +// nothing but {"id": X} names no community at all, so it offers no scope for +// the community-scoped marker a delete-before-create left for exactly that id +// — from ANY signer with a valid signature, for content that is not theirs. +// It never needs one: bare content is dropped outright before any fetch or +// tombstone lookup, and content is materialized only from its community's +// Announce, whose announcer scopes the marker check. This pins that a bare +// reference to a tombstoned id is never materialized and leaves the marker +// in place. func TestBareReferenceCreateCannotDodgeScopedTombstone(t *testing.T) { h := newHarness(t) group := h.subscribeTechnology() @@ -1759,12 +1758,15 @@ func TestAnnouncedRestoreOfChangedTypeDropped(t *testing.T) { // authorization ("the origin must serve the object again") AND the body that // goes back into the repo, so an open redirect off the origin would both // license the restore and choose its content. Pinned like the delete sweep's -// fetch (TestSweepDeletedRejectsCrossAuthorityRedirect), one call over. +// fetch (TestSweepDeletedRejectsCrossAuthorityRedirect), one call over. The +// restore is the community's Announce{Undo{Delete}}: a bare Undo{Delete} of +// mapped content is dropped before any fetch +// (TestBareUndoDeleteNeverRematerializesContent). func TestUndoDeleteRejectsCrossAuthorityRedirect(t *testing.T) { h := newHarness(t) group := h.subscribeTechnology() h.serveLemmyWorldContent() - author := h.newRemoteActor(personID, person(personID, "LeftLeaningFreedomFighters", nil)) + h.newRemoteActor(personID, person(personID, "LeftLeaningFreedomFighters", nil)) ctx := context.Background() const slug = "restore-redirect" @@ -1788,31 +1790,32 @@ func TestUndoDeleteRejectsCrossAuthorityRedirect(t *testing.T) { }) require.Equal(t, postID, h.bridgePost(group, slug)) - require.Equal(t, http.StatusAccepted, h.deliver(author, map[string]any{ - "id": "https://lemmy.world/activities/delete/" + slug, - "type": "Delete", - "actor": personID, - "object": postID, - })) - h.drain() + h.announceDelete(group, "https://lemmy.world/activities/announce/delete/"+slug, personID, postID) mapping, err := h.objects.GetByAPID(ctx, postID) require.NoError(t, err) require.True(t, mapping.IsDeleted()) - require.Equal(t, http.StatusAccepted, h.deliver(author, map[string]any{ - "id": "https://lemmy.world/activities/undo/" + slug, - "type": "Undo", - "actor": personID, + require.Equal(t, http.StatusAccepted, h.deliver(group, map[string]any{ + "id": "https://lemmy.world/activities/announce/undo/" + slug, + "type": "Announce", + "actor": groupID, + "audience": groupID, "object": map[string]any{ - "id": "https://lemmy.world/activities/delete/" + slug, - "type": "Delete", - "actor": personID, - "object": postID, + "id": "https://lemmy.world/activities/undo/" + slug, + "type": "Undo", + "actor": personID, + "audience": groupID, + "object": map[string]any{ + "id": "https://lemmy.world/activities/delete/" + slug, + "type": "Delete", + "actor": personID, + "object": postID, + }, }, })) h.drain() - event, err := h.events.GetEvent(ctx, "https://lemmy.world/activities/undo/"+slug) + event, err := h.events.GetEvent(ctx, "https://lemmy.world/activities/announce/undo/"+slug) require.NoError(t, err) assert.NotNil(t, event.FailedAt, "an off-authority redirect is permanent, so the event poisons") assert.Contains(t, event.Error, "authority", @@ -1820,9 +1823,7 @@ func TestUndoDeleteRejectsCrossAuthorityRedirect(t *testing.T) { mapping, err = h.objects.GetByAPID(ctx, postID) require.NoError(t, err) assert.True(t, mapping.IsDeleted(), "a redirected restore must not revive the record") - tombstoned, err := h.tombstones.ExistsFor(ctx, postID, "") - require.NoError(t, err) - assert.True(t, tombstoned, "...nor clear the marker") + assert.Equal(t, []string{groupID}, h.tombstoneAnnouncers(postID), "...nor clear the marker") } // oneShotMissingActors hides ONE ap id from the first bridged_actors lookup diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index d60fa5c..4051c93 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -766,6 +766,18 @@ func (h *harness) subscribeCommunityURL(apGroupID, username string) *remoteActor return group } +// firehoseOpCount counts firehose ops (any action, any repo) in collection. +func (h *harness) firehoseOpCount(collection string) int { + h.t.Helper() + count := 0 + for _, path := range h.firehoseOps() { + if strings.HasPrefix(path, collection+"/") { + count++ + } + } + return count +} + // firehoseOps flattens all firehose event op paths. func (h *harness) firehoseOps() []string { h.t.Helper() diff --git a/internal/ingest/moderation_test.go b/internal/ingest/moderation_test.go index a8d6f5a..800e81d 100644 --- a/internal/ingest/moderation_test.go +++ b/internal/ingest/moderation_test.go @@ -575,12 +575,11 @@ func TestNonAuthorDeleteWithoutSummaryKeepsAuthorRecord(t *testing.T) { // TestBareUndoDeleteDoesNotRestoreRemovedPost (F4): a removal is exited only // by an explicit moderator restore, and a BARE Undo{Delete} is not one. // -// The bare path exists so an ORIGIN can un-delete content it re-serves, and it -// is deliberately permissive: same-authority signer, existing mapping, pinned -// re-fetch. None of that says anything about a COMMUNITY's decision to remove -// the post from itself. Letting the bare path write a fresh acceptance would -// let the author's own instance overturn a moderator's removal by re-serving -// the post — the restore gate has to be the community's, not the origin's. +// A bare Undo{Delete} of mapped content is dropped as a processed skip before +// any fetch (TestBareUndoDeleteNeverRematerializesContent): content is restored +// only from its community's Announce. That is also why the author's own +// instance cannot overturn a moderator's removal by re-serving the post — the +// restore gate is the community's, not the origin's. // // The announced restore (which IS the moderator's decision) is asserted by // TestModeration_RemoveRestoreAndSelfDelete at the e2e tier and by R4 here.