diff --git a/internal/ingest/consent.go b/internal/ingest/consent.go index d629e8c..378014b 100644 --- a/internal/ingest/consent.go +++ b/internal/ingest/consent.go @@ -92,6 +92,26 @@ func (h *Handler) handleDelete(ctx context.Context, del *ap.Object, signer strin return err } + // MODERATOR REMOVAL, not a delete. Lemmy spells the two with the same + // activity and distinguishes them by `summary`: a moderator's removal + // carries the key (EMPTY when they gave no reason), an author deleting + // their own post omits it entirely. Reading one as the other either + // destroys an author's post over a moderator's hidden action or fabricates + // a moderation record against someone who moderated nobody, so the + // question asked is key PRESENCE (HasSummary), never whether the text is + // empty. + // + // It diverts before the tombstone marker and the delete dispatch below on + // purpose: the post is not going anywhere. Laying a marker would suppress + // the post's own later Creates and Updates, and deleting the record would + // hand one community the power to destroy content in all the others. + if announcer != nil && del.HasSummary() { + handled, err := h.removePostForModerator(ctx, targetID, del.Summary) + if err != nil || handled { + return err + } + } + // Record the tombstone marker BEFORE deleting: if this is a Delete for // an object we never materialized, the marker is the only thing // stopping a later (re-delivered, out-of-order) Create from @@ -121,6 +141,29 @@ func (h *Handler) handleDelete(ctx context.Context, del *ap.Object, signer strin return nil } +// removePostForModerator applies a moderator removal to a postv2, reporting +// whether it took the activity. It declines — leaving the caller on the +// ordinary delete path — for everything the postv2 moderation records do not +// describe: an id the bridge never materialized, a mapping already tombstoned, +// and above all the PRE-FLIP era, whose posts live in the community's own repo +// with no acceptance to replace. Writing a removal for a legacy post would +// announce a visibility mechanism Coves does not consult for that collection, +// so those keep the v1 behaviour exactly: delete the record, tombstone the +// mapping. +func (h *Handler) removePostForModerator(ctx context.Context, targetID, reason string) (bool, error) { + mapping, err := h.objects.GetByAPID(ctx, targetID) + if errors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("ingest: look up mapping for removal of %s: %w", targetID, err) + } + if mapping.IsDeleted() || mapping.Collection != materialize.CollectionPostV2 { + return false, nil + } + return true, h.mat.RemovePost(ctx, mapping, reason) +} + // announcerGroupID is the announcing community's AP group id, or "" for a // bare delivery. As a Tombstones scope "" means global/origin-authorized — // correct for a bare delivery, which reached here only by passing the @@ -287,6 +330,20 @@ func (h *Handler) handleUndoDelete(ctx context.Context, undo, del *ap.Object, si "ap_id", targetID, "reason", err) return err } + // Undo of a moderator REMOVAL. That removal deleted no record and + // tombstoned no mapping, so everything above was a no-op for it: the + // re-materialization put the post back exactly as it already was, and its + // acceptance write was refused by the terminality guard because the + // removal still stands. Lifting it is a separate transition — delete the + // removal, re-accept the CURRENT version, one commit — and it runs AFTER + // the re-materialization so the version it pins is the one now in the + // repo. RestorePost is a no-op when no removal stands, which is every + // ordinary restore. + if mapping.Collection == materialize.CollectionPostV2 { + if err := h.mat.RestorePost(ctx, mapping); err != nil { + return err + } + } return nil } diff --git a/internal/ingest/handler.go b/internal/ingest/handler.go index 5009df4..0f28413 100644 --- a/internal/ingest/handler.go +++ b/internal/ingest/handler.go @@ -23,6 +23,12 @@ type Materializer interface { // latter — see materialize.HandleDeleteRecord for the TOCTOU it closes. HandleDelete(ctx context.Context, apID string) error HandleDeleteRecord(ctx context.Context, apID string) error + // RemovePost/RestorePost are the community-scoped moderation transitions: + // they rewrite the community's acceptance and removal records and leave + // the author's post where it is. Deleting content is a different verb, so + // these are not reachable through the delete entry points above. + RemovePost(ctx context.Context, mapping *store.APObjectMapping, reason string) error + RestorePost(ctx context.Context, mapping *store.APObjectMapping) error RefreshActor(ctx context.Context, actorRef *ap.Object) (*store.BridgedActor, error) RefreshCommunity(ctx context.Context, groupRef *ap.Object) (*store.Community, error) EnsureCommunity(ctx context.Context, groupRef *ap.Object) (*store.Community, error) diff --git a/internal/ingest/moderation_test.go b/internal/ingest/moderation_test.go new file mode 100644 index 0000000..671c649 --- /dev/null +++ b/internal/ingest/moderation_test.go @@ -0,0 +1,485 @@ +package ingest + +import ( + "context" + "crypto/sha256" + "encoding/base32" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" + "tidepool/internal/materialize" + "tidepool/internal/repo" + "tidepool/internal/store" +) + +// Lemmy moderation, end to end through the announced-delete path. +// +// The wire shapes here are the ones captured off live Lemmy 0.19.20: a mod +// removal is Announce{Delete} whose INNER Delete is attributed to the +// moderator (a /u/ actor, not the community) and carries a `summary`; the +// summary is an EMPTY STRING when the moderator gave no reason. A self-delete +// is the same activity with NO summary key. A restore is +// Announce{Undo{Delete}} carrying the delete inline. +// +// The distinction is the whole point: mod removal leaves the author's post +// intact and records a community-side removal, while a self-delete takes the +// post away. Reading one as the other either destroys an author's post over a +// moderator's hidden action, or fabricates a moderation record against an +// author who moderated nobody. + +const modActorID = "https://lemmy.world/u/moderator" + +// testDigestRKey re-derives the acceptance/removal record key INDEPENDENTLY +// (unpadded lowercase base32 of SHA-256 of the subject at-uri). Deliberately +// not materialize.SubjectRKey: this tier asserts the rkey the bridge actually +// wrote to, and a test that called the production helper could not detect a +// change in it because both sides would move together. The golden vectors +// live in internal/materialize/subject_rkey_test.go. +func testDigestRKey(subjectURI string) string { + digest := sha256.Sum256([]byte(subjectURI)) + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(digest[:])) +} + +// moderatedPost is the fixture every test here starts from: the captured +// lemmy.world page, announced into a followed community and materialized as a +// postv2 in the author's repo plus an acceptance in the community's. +type moderatedPost struct { + group *remoteActor + communityDID string + authorDID string + rkey string + postURI string + digestRKey string + postCID string + acceptedCID string +} + +func setupModeratedPost(t *testing.T, h *harness) moderatedPost { + t.Helper() + group := h.subscribeTechnology() + h.serveLemmyWorldContent() + ctx := context.Background() + + require.Equal(t, http.StatusAccepted, + h.deliver(group, loadFixture(t, "announce_create_page_lemmy_world.json"))) + h.drain() + + mapping, err := h.objects.GetByAPID(ctx, pageID) + require.NoError(t, err) + require.False(t, mapping.IsDeleted()) + require.Equal(t, materialize.CollectionPostV2, mapping.Collection, + "precondition: the post materialized as a postv2") + + communityDID := testDIDFor("technology", "lemmy.world") + authorDID := testDIDFor("LeftLeaningFreedomFighters", "lemmy.world") + postURI := "at://" + authorDID + "/" + materialize.CollectionPostV2 + "/" + mapping.RKey + digest := testDigestRKey(postURI) + + _, postCID, err := h.manager.GetRecord(ctx, authorDID, materialize.CollectionPostV2, mapping.RKey) + require.NoError(t, err) + + acceptance, _, err := h.manager.GetRecord(ctx, communityDID, materialize.CollectionAcceptance, digest) + require.NoError(t, err, "precondition: the community accepted the post") + subject, ok := acceptance["subject"].(map[string]any) + require.True(t, ok) + + return moderatedPost{ + group: group, + communityDID: communityDID, + authorDID: authorDID, + rkey: mapping.RKey, + postURI: postURI, + digestRKey: digest, + postCID: postCID, + acceptedCID: strongRefCID(subject["cid"]), + } +} + +// strongRefCID normalizes a strongRef cid (typed CID-link or {"$link":...}). +func strongRefCID(v any) string { + switch value := v.(type) { + case string: + return value + case map[string]any: + if link, ok := value["$link"].(string); ok { + return link + } + case interface{ String() string }: + return value.String() + } + return "" +} + +// announceDeleteWithSummary delivers the live mod-removal shape: the inner +// Delete is the MODERATOR's, announced by the community, with `summary` +// PRESENT. A nil summary omits the key entirely — the self-delete shape. +func (h *harness) announceDeleteWithSummary(group *remoteActor, activityID, targetID string, summary *string) { + h.t.Helper() + inner := map[string]any{ + "id": activityID + "/delete", + "type": "Delete", + "actor": modActorID, + "object": targetID, + "audience": group.id, + "cc": []any{group.id}, + } + if summary != nil { + inner["summary"] = *summary + } + require.Equal(h.t, http.StatusAccepted, h.deliver(group, map[string]any{ + "id": activityID, + "type": "Announce", + "actor": group.id, + "audience": group.id, + "cc": []any{group.id + "/followers"}, + "object": inner, + })) + h.drain() +} + +// announceUndoDelete delivers the live restore shape: Announce{Undo{Delete}} +// with the delete activity carried INLINE (Lemmy embeds it rather than +// referencing its id). +func (h *harness) announceUndoDelete(group *remoteActor, activityID, deleteActivityID, targetID string, summary *string) { + h.t.Helper() + inner := map[string]any{ + "id": deleteActivityID, + "type": "Delete", + "actor": modActorID, + "object": targetID, + "audience": group.id, + "cc": []any{group.id}, + } + if summary != nil { + inner["summary"] = *summary + } + require.Equal(h.t, http.StatusAccepted, h.deliver(group, map[string]any{ + "id": activityID, + "type": "Announce", + "actor": group.id, + "audience": group.id, + "cc": []any{group.id + "/followers"}, + "object": map[string]any{ + "id": activityID + "/undo", + "type": "Undo", + "actor": modActorID, + "audience": group.id, + "cc": []any{group.id}, + "object": inner, + }, + })) + h.drain() +} + +// communityEvents returns the firehose events for one repo. +func communityEvents(t *testing.T, h *harness, did string) []*repo.Event { + t.Helper() + events, err := h.manager.ListEvents(context.Background(), 0, 1000) + require.NoError(t, err) + var out []*repo.Event + for _, event := range events { + if event.DID == did { + out = append(out, event) + } + } + return out +} + +// TestModRemovalWritesRemovalAtomically (R1): the flow the task exists for. +// One commit in the community repo turns "accepted" into "removed"; the +// author's post is untouched, because a mod removing a post from a community +// does not delete what the author wrote. +func TestModRemovalWritesRemovalAtomically(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + eventsBefore := len(communityEvents(t, h, post.communityDID)) + + reason := "spam wave" + h.announceDeleteWithSummary(post.group, + "https://lemmy.world/activities/announce/delete/mod-removal", pageID, &reason) + + // ONE commit carrying BOTH ops: a consumer must never see the acceptance + // gone without the removal present, or the post reads as neither accepted + // nor removed. + events := communityEvents(t, h, post.communityDID) + require.Len(t, events, eventsBefore+1, + "a mod removal must be exactly one community-repo commit") + ops := events[len(events)-1].Ops + require.Len(t, ops, 2, "the acceptance delete and the removal write ride one commit, got %v", ops) + byPath := map[string]repo.Op{} + for _, op := range ops { + byPath[op.Path] = op + } + acceptanceOp, ok := byPath[materialize.CollectionAcceptance+"/"+post.digestRKey] + require.True(t, ok, "the acceptance delete must be on the commit, got %v", byPath) + assert.Equal(t, repo.OpActionDelete, acceptanceOp.Action) + _, ok = byPath[materialize.CollectionRemoval+"/"+post.digestRKey] + require.True(t, ok, "the removal write must be on the SAME commit, got %v", byPath) + + // The acceptance is gone. + _, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + assert.True(t, errors.IsNotFound(err), "a removed post must not keep its acceptance (err=%v)", err) + + // The removal names the post, at the SAME digest rkey the acceptance had. + removal, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + require.NoError(t, err, + "a mod removal must write a removal record at the subject digest rkey") + assert.Equal(t, materialize.CollectionRemoval, removal["$type"]) + subject, ok := removal["subject"].(map[string]any) + require.True(t, ok, "removal.subject must be a strongRef, got %#v", removal["subject"]) + assert.Equal(t, post.postURI, subject["uri"]) + assert.Equal(t, post.acceptedCID, strongRefCID(subject["cid"]), + "the removal pins the version that was accepted at removal time (audit metadata)") + assert.Equal(t, "moderator-discretion", removal["code"], + "Lemmy sends no machine-readable code, so the open knownValues set's default applies") + assert.Equal(t, reason, removal["reason"], "the moderator's text is the human-readable reason") + assert.NotEmpty(t, removal["createdAt"]) + + // The author's post lives on, and so does its mapping. + _, _, err = h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + assert.NoError(t, err, + "a community removing a post must not delete the author's record — removal is community-scoped") + mapping, err := h.objects.GetByAPID(ctx, pageID) + require.NoError(t, err) + assert.False(t, mapping.IsDeleted(), + "the post still exists; tombstoning its mapping would block every later edit and vote") +} + +// TestModRemovalWithEmptySummary (R2): Lemmy sends `"summary": ""` when the +// moderator typed no reason. That is the ORDINARY removal, not an edge case, +// and it must produce a removal with the default code and no reason field — +// an empty-string reason would render as a blank explanation in the +// moderation log rather than as "none given". +func TestModRemovalWithEmptySummary(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + + empty := "" + h.announceDeleteWithSummary(post.group, + "https://lemmy.world/activities/announce/delete/mod-removal-noreason", pageID, &empty) + + _, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + assert.True(t, errors.IsNotFound(err), "the acceptance must be gone (err=%v)", err) + + removal, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + require.NoError(t, err, + "a PRESENT-but-empty summary is still a mod removal: it is how Lemmy spells "+ + "'removed, no reason given'") + assert.Equal(t, "moderator-discretion", removal["code"]) + assert.NotContains(t, removal, "reason", + "no reason was given, so the field is omitted rather than written blank") + assert.NotEmpty(t, removal["createdAt"]) + + _, _, err = h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + assert.NoError(t, err, "the author's post survives a reasonless removal too") +} + +// TestSelfDeleteRemovesPostAndAcceptance (R3): no summary key means the AUTHOR +// deleted their own post. The post goes, its acceptance goes with it (an +// acceptance whose subject is gone is inert), and NO removal is written — +// author deletion is not moderation, and recording it as such would put a +// moderation action in the log against someone who was never moderated. +func TestSelfDeleteRemovesPostAndAcceptance(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + + h.announceDeleteWithSummary(post.group, + "https://lemmy.world/activities/announce/delete/self", pageID, nil) + + _, _, err := h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + assert.True(t, errors.IsNotFound(err), + "a self-delete removes the author's post (err=%v)", err) + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + assert.True(t, errors.IsNotFound(err), + "the acceptance of a deleted post must not linger (err=%v)", err) + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + assert.True(t, errors.IsNotFound(err), + "an author deleting their own post is not a moderation action: no removal record (err=%v)", err) + + mapping, err := h.objects.GetByAPID(ctx, pageID) + require.NoError(t, err) + assert.True(t, mapping.IsDeleted(), + "v1 tombstone semantics: a self-deleted post's mapping is soft-deleted so a "+ + "re-delivered Create cannot resurrect it") +} + +// TestRestoreDeletesRemovalAndReacceptsAtomically (R4): a moderator undoing +// their removal must leave the community in the state it was in before — +// accepted, pinning the CURRENT post version — and must do it in ONE commit, +// for the same reason the removal was atomic. +func TestRestoreDeletesRemovalAndReacceptsAtomically(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + + reason := "spam wave" + h.announceDeleteWithSummary(post.group, + "https://lemmy.world/activities/announce/delete/to-be-undone", pageID, &reason) + _, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + require.NoError(t, err, "precondition: the removal landed") + + eventsBefore := len(communityEvents(t, h, post.communityDID)) + + h.announceUndoDelete(post.group, + "https://lemmy.world/activities/announce/undo/restore", + "https://lemmy.world/activities/announce/delete/to-be-undone/delete", + pageID, &reason) + + events := communityEvents(t, h, post.communityDID) + require.Len(t, events, eventsBefore+1, + "a restore must be exactly one community-repo commit") + ops := events[len(events)-1].Ops + require.Len(t, ops, 2, "the removal delete and the fresh acceptance ride one commit, got %v", ops) + byPath := map[string]repo.Op{} + for _, op := range ops { + byPath[op.Path] = op + } + removalOp, ok := byPath[materialize.CollectionRemoval+"/"+post.digestRKey] + require.True(t, ok, "the removal delete must be on the commit, got %v", byPath) + assert.Equal(t, repo.OpActionDelete, removalOp.Action) + _, ok = byPath[materialize.CollectionAcceptance+"/"+post.digestRKey] + require.True(t, ok, "the fresh acceptance must be on the SAME commit, got %v", byPath) + + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionRemoval, post.digestRKey) + assert.True(t, errors.IsNotFound(err), "the removal must be gone after a restore (err=%v)", err) + + acceptance, _, err := h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + require.NoError(t, err, "a restore re-accepts the post") + subject, ok := acceptance["subject"].(map[string]any) + require.True(t, ok) + assert.Equal(t, post.postURI, subject["uri"]) + _, currentCID, err := h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + require.NoError(t, err) + assert.Equal(t, currentCID, strongRefCID(subject["cid"]), + "the fresh acceptance pins the CURRENT post version, not the one removed") + + mapping, err := h.objects.GetByAPID(ctx, pageID) + require.NoError(t, err) + assert.False(t, mapping.IsDeleted(), "a restored post's mapping is live") +} + +// TestRemovalSurvivesRedeliveredCreate (R5) is the terminality guard. A +// removal is exited ONLY by an explicit restore. Redelivery of the original +// Create is routine — the queue re-runs it, and so does a backfill — and +// re-materializing the post drives the acceptance write. If nothing stops +// that write, a fresh acceptance IS a restore: the post reappears in the +// community, having been un-removed by a redelivery nobody intended as one. +func TestRemovalSurvivesRedeliveredCreate(t *testing.T) { + h := newHarness(t) + post := setupModeratedPost(t, h) + ctx := context.Background() + + reason := "spam wave" + h.announceDeleteWithSummary(post.group, + "https://lemmy.world/activities/announce/delete/terminal", pageID, &reason) + removalBefore, removalCIDBefore, err := h.manager.GetRecord(ctx, + post.communityDID, materialize.CollectionRemoval, post.digestRKey) + require.NoError(t, err, "precondition: the removal landed") + + // The same post is announced again under a FRESH announce id — what a + // real re-announce looks like, and the only shape that reaches the + // materializer at all: the inbox dedupes by activity id, so replaying the + // original announce would be absorbed there and prove nothing about the + // terminality guard. + const reAnnounceID = "https://lemmy.world/activities/announce/create/re-announce" + h.announceCreate(post.group, reAnnounceID, loadFixture(t, "page_lemmy_world.json")) + + // Self-proof: the re-announce must actually have been PROCESSED, not + // absorbed by inbox dedupe or dropped by authorization. Without this the + // assertions below would pass on an activity that never reached the + // materializer, and the guard would be untested. + event, err := h.events.GetEvent(ctx, reAnnounceID) + require.NoError(t, err, "the re-announce must have been accepted as a new activity") + require.NotNil(t, event.ProcessedAt, "the re-announce must have been processed") + require.Empty(t, event.Error) + _, _, err = h.manager.GetRecord(ctx, post.authorDID, materialize.CollectionPostV2, post.rkey) + require.NoError(t, err, "the re-announce re-materialized the post itself") + + _, _, err = h.manager.GetRecord(ctx, post.communityDID, materialize.CollectionAcceptance, post.digestRKey) + assert.True(t, errors.IsNotFound(err), + "a redelivered Create must NOT re-accept a removed post: a fresh acceptance is exactly "+ + "what a restore is, so this would silently un-remove it (err=%v)", err) + + removalAfter, removalCIDAfter, err := h.manager.GetRecord(ctx, + post.communityDID, materialize.CollectionRemoval, post.digestRKey) + require.NoError(t, err, "the removal must still stand") + assert.Equal(t, removalCIDBefore, removalCIDAfter, "the removal record must not be rewritten") + assert.Equal(t, removalBefore["createdAt"], removalAfter["createdAt"]) +} + +// TestLegacyPostModRemovalKeepsV1Semantics (R6) is the mixed-era control. +// Moderation records are postv2-only in this task: a pre-flip post has no +// acceptance to delete, and writing a removal for it would announce a +// visibility mechanism Coves does not consult for that collection. The v1 +// behaviour — delete the record, tombstone the mapping — must be untouched. +func TestLegacyPostModRemovalKeepsV1Semantics(t *testing.T) { + h := newHarness(t) + group := h.subscribeTechnology() + h.serveLemmyWorldContent() + ctx := context.Background() + + communityDID := testDIDFor("technology", "lemmy.world") + authorDID := testDIDFor("LeftLeaningFreedomFighters", "lemmy.world") + + // A pre-flip post: in the COMMUNITY's repo, deprecated collection, with + // the in-record author the old lexicon required. + const legacyAPID = "https://lemmy.world/post/424242" + const legacyRKey = "3kjzl5kcb2s2v" + published := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + commit, err := h.manager.PutRecord(ctx, communityDID, materialize.CollectionPost, legacyRKey, + map[string]any{ + "$type": materialize.CollectionPost, + "community": communityDID, + "author": authorDID, + "createdAt": "2026-06-01T09:00:00.000Z", + "title": "a pre-flip post", + }) + require.NoError(t, err) + _, err = h.objects.PutMapping(ctx, store.APObjectMapping{ + APID: legacyAPID, + APType: "Page", + OriginInstance: "lemmy.world", + Origin: store.OriginFediverse, + DID: communityDID, + AuthorDID: authorDID, + CommunityDID: communityDID, + Collection: materialize.CollectionPost, + RKey: legacyRKey, + CID: commit.RecordCID, + PublishedAt: &published, + }) + require.NoError(t, err) + + reason := "spam wave" + h.announceDeleteWithSummary(group, + "https://lemmy.world/activities/announce/delete/legacy", legacyAPID, &reason) + + _, _, err = h.manager.GetRecord(ctx, communityDID, materialize.CollectionPost, legacyRKey) + assert.True(t, errors.IsNotFound(err), + "v1 semantics: a moderated legacy post's record is deleted outright (err=%v)", err) + + mapping, err := h.objects.GetByAPID(ctx, legacyAPID) + require.NoError(t, err) + assert.True(t, mapping.IsDeleted(), "v1 semantics: the mapping is tombstoned") + + legacyURI := "at://" + communityDID + "/" + materialize.CollectionPost + "/" + legacyRKey + _, _, err = h.manager.GetRecord(ctx, communityDID, materialize.CollectionRemoval, testDigestRKey(legacyURI)) + assert.True(t, errors.IsNotFound(err), + "moderation records are postv2-only in this task: a legacy post writes none (err=%v)", err) + + entries, err := h.manager.ListRecords(ctx, communityDID) + require.NoError(t, err) + for _, entry := range entries { + assert.NotEqual(t, materialize.CollectionRemoval, entry.Collection, + "no removal record may exist for the legacy era (rkey %s)", entry.Rkey) + } +} diff --git a/internal/materialize/acceptance.go b/internal/materialize/acceptance.go index 262b067..ca014e4 100644 --- a/internal/materialize/acceptance.go +++ b/internal/materialize/acceptance.go @@ -8,6 +8,7 @@ import ( "tidepool/internal/errors" "tidepool/internal/repo" + "tidepool/internal/store" ) // acceptPost writes the community's acceptance of one post: the attestation @@ -40,6 +41,24 @@ import ( func (m *Materializer) acceptPost(ctx context.Context, communityDID, postURI, postCID string, publishedAt time.Time) error { rkey := SubjectRKey(postURI) + // TERMINALITY. A removal is exited only by an explicit restore, and a + // fresh acceptance IS a restore — so writing one here would un-remove the + // post. This path is reached by every redelivery and every backfill pass, + // neither of which is anybody's decision to reinstate content, so a + // standing removal wins and the acceptance is simply not written. The + // restore path does not come through here: it deletes the removal and + // writes the acceptance in ONE commit (RestorePost), so it never has to + // argue with this guard. + removed, err := m.removalStands(ctx, communityDID, rkey) + if err != nil { + return err + } + if removed { + m.logger.Debug("post is removed from the community; not re-accepting", + "community_did", communityDID, "post", postURI) + return nil + } + // Read-modify-write under a CAS precondition, bounded like the stats // stamp: the read happens outside the commit serialization, so a racing // repin (or a stats-driven CID change on the subject) can move the record @@ -94,3 +113,197 @@ func (m *Materializer) acceptPost(ctx context.Context, communityDID, postURI, po return nil } } + +// removalStands reports whether the community currently holds a removal for +// the subject at rkey. Acceptance and removal share the digest key, so this is +// a lookup rather than a search. +func (m *Materializer) removalStands(ctx context.Context, communityDID, rkey string) (bool, error) { + _, _, err := m.repos.GetRecord(ctx, communityDID, CollectionRemoval, rkey) + switch { + case err == nil: + return true, nil + case errors.IsNotFound(err): + return false, nil + default: + return false, fmt.Errorf("materialize: read removal %s/%s/%s: %w", + communityDID, CollectionRemoval, rkey, err) + } +} + +// RemovePost records a community's moderator removal of a post: the acceptance +// is deleted and a removal written IN ONE COMMIT, at the same digest rkey. +// +// Atomicity is the requirement, not an optimization. Two commits would put a +// window on the firehose where the acceptance is gone and the removal is not +// yet there, and a consumer reading that window sees a post that is neither +// accepted nor removed — a state nobody decided on. +// +// The AUTHOR'S POST IS NOT TOUCHED, and neither is its mapping. A community +// removing a post says where the post may appear, not whether it exists; +// deleting the author's record would let one community destroy content for +// every other, and tombstoning the mapping would block the post's later edits +// and votes from ever materializing again. +func (m *Materializer) RemovePost(ctx context.Context, mapping *store.APObjectMapping, reason string) error { + communityDID, postURI, rkey, err := m.moderationTarget(ctx, mapping) + if err != nil { + return err + } + + // The removal pins the version that was accepted when it was removed — + // audit metadata, per the lexicon, since removal itself applies to the URI + // across later edits. Read BEFORE the delete, or the pin is gone with it. + // A missing acceptance (a crash-window heal, or a removal arriving before + // the acceptance ever landed) falls back to the mapping's CID: the removal + // is terminal on the URI either way, so an approximate pin is better than + // refusing to record the moderator's decision. + pinned := mapping.CID + if acceptance, _, aerr := m.repos.GetRecord(ctx, communityDID, CollectionAcceptance, rkey); aerr == nil { + if ref, ok := extractStrongRef(acceptance, "subject"); ok { + pinned, _ = ref["cid"].(string) + } + } else if !errors.IsNotFound(aerr) { + return fmt.Errorf("materialize: read acceptance %s/%s/%s: %w", + communityDID, CollectionAcceptance, rkey, aerr) + } + + removal := map[string]any{ + "$type": CollectionRemoval, + "subject": strongRef(postURI, pinned), + // Lemmy sends no machine-readable code, so the open knownValues set's + // catch-all applies. Inventing a narrower code (spam, rule-violation) + // would be the bridge asserting a reason the moderator never gave. + "code": "moderator-discretion", + "createdAt": recordDatetime(m.moderationTime(mapping)), + } + // Omitted rather than written blank: Lemmy spells "no reason given" as an + // empty summary, and an empty reason renders in a moderation log as a + // blank explanation instead of as none. + if reason != "" { + removal["reason"] = reason + } + if err := m.validateRecord(removal); err != nil { + return err + } + + if _, err := m.repos.ApplyOps(ctx, communityDID, []repo.RecordOp{ + {Action: repo.OpActionDelete, Collection: CollectionAcceptance, RKey: rkey}, + {Action: repo.OpActionUpdate, Collection: CollectionRemoval, RKey: rkey, Record: removal}, + }); err != nil { + return fmt.Errorf("materialize: remove %s from %s: %w", postURI, communityDID, err) + } + m.logger.Info("post removed from community by moderator", + "community_did", communityDID, "post", postURI, "ap_id", mapping.APID) + return nil +} + +// RestorePost undoes a moderator removal: the removal is deleted and a fresh +// acceptance written IN ONE COMMIT, for the same reason the removal was +// atomic. It is a no-op when no removal stands, so a restore that arrives +// twice — or one for a post that was never removed — costs a read. +// +// The fresh acceptance pins the post's CURRENT version, not the one that was +// removed: the author may have edited it while it was out of the community, +// and re-accepting a version that is no longer there would leave the post +// pending re-acceptance the moment it came back. +func (m *Materializer) RestorePost(ctx context.Context, mapping *store.APObjectMapping) error { + communityDID, postURI, rkey, err := m.moderationTarget(ctx, mapping) + if err != nil { + return err + } + removed, err := m.removalStands(ctx, communityDID, rkey) + if err != nil { + return err + } + if !removed { + return nil + } + + _, currentCID, err := m.repos.GetRecord(ctx, mapping.DID, mapping.Collection, mapping.RKey) + if err != nil { + // No post to re-accept. The removal stays: it is terminal on the URI, + // and an acceptance pinning nothing would be worse than none. + if errors.IsNotFound(err) { + m.logger.Warn("restore target has no record to re-accept; leaving the removal in place", + "ap_id", mapping.APID, "at_uri", mapping.ATURI) + return nil + } + return fmt.Errorf("materialize: read %s for restore: %w", mapping.ATURI, err) + } + + acceptance := map[string]any{ + "$type": CollectionAcceptance, + "subject": strongRef(postURI, currentCID), + "createdAt": recordDatetime(m.moderationTime(mapping)), + } + if err := m.validateRecord(acceptance); err != nil { + return err + } + + if _, err := m.repos.ApplyOps(ctx, communityDID, []repo.RecordOp{ + {Action: repo.OpActionDelete, Collection: CollectionRemoval, RKey: rkey}, + {Action: repo.OpActionUpdate, Collection: CollectionAcceptance, RKey: rkey, Record: acceptance}, + }); err != nil { + return fmt.Errorf("materialize: restore %s into %s: %w", postURI, communityDID, err) + } + m.logger.Info("post restored to community by moderator", + "community_did", communityDID, "post", postURI, "ap_id", mapping.APID) + return nil +} + +// moderationTarget resolves the community, subject uri and digest rkey a +// moderation transition acts on, refusing anything that is not a postv2. +// Moderation records are postv2-only: a pre-flip post has no acceptance to +// replace, and writing a removal for one would announce a visibility +// mechanism Coves does not consult for that collection. +func (m *Materializer) moderationTarget(ctx context.Context, mapping *store.APObjectMapping) (communityDID, postURI, rkey string, err error) { + if mapping == nil { + return "", "", "", errors.NewValidationError("mapping", "must not be nil") + } + if mapping.Collection != CollectionPostV2 { + return "", "", "", errors.NewValidationError("mapping", + "moderation records are only written for "+CollectionPostV2+", got "+mapping.Collection) + } + communityDID, err = CommunityDIDOf(ctx, m.repos, mapping) + if err != nil { + return "", "", "", err + } + if communityDID == "" { + return "", "", "", errors.NewValidationError("mapping", + "cannot moderate "+mapping.ATURI+": it binds to no community") + } + return communityDID, mapping.ATURI, SubjectRKey(mapping.ATURI), nil +} + +// moderationTime is the timestamp a moderation record carries. It is derived +// from the post, exactly as acceptPost's createdAt is, so a redelivered +// moderation activity rebuilds byte-identical bytes and reaches the repo +// layer's no-op path instead of churning the community repo on every retry. +func (m *Materializer) moderationTime(mapping *store.APObjectMapping) time.Time { + if mapping.PublishedAt != nil { + return *mapping.PublishedAt + } + // Unreachable for a postv2 (no published time means no deterministic rkey, + // so the post never materialized), but a wall-clock fallback keeps the + // record writable rather than dropping a moderator's decision. + return m.now() +} + +// deleteAcceptance removes a post's acceptance from its community. Called +// before the post record itself goes: a crash in between then leaves a post +// that is merely invisible, where the reverse order would leave the community +// attesting to a record that no longer exists. +func (m *Materializer) deleteAcceptance(ctx context.Context, mapping *store.APObjectMapping) error { + communityDID, err := CommunityDIDOf(ctx, m.repos, mapping) + if err != nil { + return err + } + if communityDID == "" { + return nil + } + rkey := SubjectRKey(mapping.ATURI) + if _, err := m.repos.DeleteRecord(ctx, communityDID, CollectionAcceptance, rkey); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("materialize: delete acceptance %s/%s/%s: %w", + communityDID, CollectionAcceptance, rkey, err) + } + return nil +} diff --git a/internal/materialize/hardening_test.go b/internal/materialize/hardening_test.go index 27e99d3..c5749cc 100644 --- a/internal/materialize/hardening_test.go +++ b/internal/materialize/hardening_test.go @@ -101,7 +101,7 @@ func TestDeleteActor_AccountEventVoteAndBlobScrub(t *testing.T) { } } assert.Equal(t, 1, accountEvents, "exactly one account event for one Delete(Actor)") - assert.Equal(t, 2, deleteOps, "scrub deletes: the post and the actor profile") + assert.Equal(t, 3, deleteOps, "scrub deletes: the acceptance, the post and the actor profile") } // TestSuppressActor_ScrubsVotesToo: the reversible nobridge scrub erases diff --git a/internal/materialize/materializer.go b/internal/materialize/materializer.go index 529cea0..438cb98 100644 --- a/internal/materialize/materializer.go +++ b/internal/materialize/materializer.go @@ -68,7 +68,11 @@ const ( // at-uri (SubjectRKey), and it is what makes a postv2 visible in the // community at all. CollectionAcceptance = "social.coves.community.acceptance" - CollectionComment = "social.coves.community.comment" + // CollectionRemoval is the community's record that a post was removed + // from it. It shares the acceptance's digest rkey (one derivation per + // subject) and replaces the acceptance in one atomic commit. + CollectionRemoval = "social.coves.community.removal" + CollectionComment = "social.coves.community.comment" ) // ProfileRKey is the fixed record key of actor and community profiles. diff --git a/internal/materialize/updates.go b/internal/materialize/updates.go index 8e74adf..7ff3faf 100644 --- a/internal/materialize/updates.go +++ b/internal/materialize/updates.go @@ -276,6 +276,19 @@ func (m *Materializer) deleteMapping(ctx context.Context, mapping *store.APObjec if mapping.IsDeleted() { return nil } + // A postv2's acceptance is the community's attestation ABOUT this record, + // so it goes with it — and it goes FIRST. A crash after the acceptance is + // deleted leaves a post that is merely invisible in the community; the + // reverse order leaves the community attesting to a record that no longer + // exists, which is the state a consumer cannot make sense of. Every scrub + // path (consent revocation, Delete{Actor}, the delete sweep) inherits this + // by coming through here. No REMOVAL is written: an author deleting their + // own post, or a scrub, is not a moderation action. + if mapping.Collection == CollectionPostV2 { + if err := m.deleteAcceptance(ctx, mapping); err != nil { + return err + } + } if _, err := m.repos.DeleteRecord(ctx, mapping.DID, mapping.Collection, mapping.RKey); err != nil && !errors.IsNotFound(err) { return fmt.Errorf("materialize: delete record %s: %w", mapping.ATURI, err) } diff --git a/internal/votes/e2e_test.go b/internal/votes/e2e_test.go index b5f7380..6e32bb4 100644 --- a/internal/votes/e2e_test.go +++ b/internal/votes/e2e_test.go @@ -72,6 +72,16 @@ func (s *stubMaterializer) EnsureCommunity(context.Context, *ap.Object) (*store. return nil, nil } +func (s *stubMaterializer) RemovePost(context.Context, *store.APObjectMapping, string) error { + s.t.Fatal("votes must never reach RemovePost") + return nil +} + +func (s *stubMaterializer) RestorePost(context.Context, *store.APObjectMapping) error { + s.t.Fatal("votes must never reach RestorePost") + return nil +} + // stubFetcher fails loudly on any fetch: inline vote activities must be // dispatched without touching the network. type stubFetcher struct{ t *testing.T }