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.