diff --git a/backend/internal/store/migrations/0010_reader_sightings.sql b/backend/internal/store/migrations/0010_reader_sightings.sql new file mode 100644 index 0000000..13fb4e2 --- /dev/null +++ b/backend/internal/store/migrations/0010_reader_sightings.sql @@ -0,0 +1,6 @@ +-- The owner's administrative page (issue #102) renders these counters and +-- offers a control to clear them, deliberately shipped before the Sighting +-- feature (issue #103) that fills them, so a false mark never needs SQL +-- against production. Zero counters mean a trusted Reader. +ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0; +ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0; diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index f9f8d5d..7df73aa 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e return id, nil } +// SightingDisagreementLimit is the number of contradictions that stop that +// Reader's Sightings from deferring a Poll (issue #103). The counters exist +// before the mechanism that moves them, so the admin page (issue #102) can +// clear a false mark without waiting for the Sighting feature. +const SightingDisagreementLimit = 3 + +// Blocked reports whether this Reader's Sighting marks have reached the +// disagreement limit, which stops their Sightings from deferring a Poll. +func (r ReaderSummary) Blocked() bool { + return r.Disagreements >= SightingDisagreementLimit +} + // ReaderSummary is one Reader as the owner's administration panel sees them: -// who they are and how many live sessions they hold. No credential material, -// hashed or otherwise, is exposed. +// who they are, how many live sessions they hold, and their Sighting marks. +// No credential material, hashed or otherwise, is exposed. type ReaderSummary struct { ID int64 DiscordID string // Sessions counts unexpired session rows — what the owner revokes. Sessions int + // Agreements and Disagreements are the Sighting counters (issue #102); + // zero means a trusted Reader. + Agreements int + Disagreements int } -// Readers lists every Reader with their live session count, oldest first, so -// the owner row (always the oldest) heads the list. +// Readers lists every Reader with their live session count and Sighting +// marks, oldest first, so the owner row (always the oldest) heads the list. func (s *Store) Readers() ([]ReaderSummary, error) { rows, err := s.db.Query(` SELECT r.id, r.discord_id, + r.sighting_agreements, r.sighting_disagreements, count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions FROM readers r LEFT JOIN sessions sess ON sess.reader_id = r.id - GROUP BY r.id, r.discord_id + GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements ORDER BY r.id`) if err != nil { return nil, fmt.Errorf("query readers: %w", err) @@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) { out := []ReaderSummary{} for rows.Next() { var r ReaderSummary - if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil { + if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil { return nil, fmt.Errorf("scan reader: %w", err) } out = append(out, r) @@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) { return out, rows.Err() } +// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's +// remedy for a mark produced by a broken Site adapter rather than a dishonest +// Reader: it restores a privilege, it is not destruction. +func (s *Store) ClearReaderMarks(readerID int64) error { + if _, err := s.db.Exec(` + UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0 + WHERE id = $1`, readerID); err != nil { + return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err) + } + return nil +} + // readersMigration is the version that creates the readers table. The owner // seed runs between two migrate passes, so that the run-once migration which // attaches existing bookmarks (0004) finds the owner row. diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 93d93ab..0ae24b2 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) { } } +// The Sighting counters ship before the mechanism that fills them (issue +// #102 before #103), so the admin page depends on their default: a fresh +// Reader reads back trusted. Marking one directly proves Readers() reports +// the counters and Blocked() flips at the limit, and that ClearReaderMarks — +// the owner's remedy for a false mark — zeroes them again. +func TestReaderSightingMarks(t *testing.T) { + s := newTestStore(t) + other := secondReader(t, s) + + readers, err := s.Readers() + if err != nil { + t.Fatalf("Readers: %v", err) + } + if len(readers) != 2 { + t.Fatalf("readers = %d, want the owner and the second Reader", len(readers)) + } + for _, r := range readers { + if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() { + t.Fatalf("fresh reader %d has marks: %+v", r.ID, r) + } + } + + // The counters' default is the trusted state; writing them directly is + // the only way to exercise the read path until issue #103 moves them. + if _, err := s.db.Exec(` + UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2 + WHERE id = $1`, other); err != nil { + t.Fatalf("mark reader: %v", err) + } + readers, err = s.Readers() + if err != nil { + t.Fatalf("Readers: %v", err) + } + var marked *ReaderSummary + for i := range readers { + if readers[i].ID == other { + marked = &readers[i] + } + } + if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 { + t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked) + } + if marked.Blocked() { + t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit) + } + + if _, err := s.db.Exec(` + UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil { + t.Fatalf("block reader: %v", err) + } + readers, err = s.Readers() + if err != nil { + t.Fatalf("Readers: %v", err) + } + for _, r := range readers { + if r.ID == other && !r.Blocked() { + t.Fatalf("reader at the disagreement limit is not blocked: %+v", r) + } + if r.ID == s.OwnerID() && r.Blocked() { + t.Fatalf("untouched owner became blocked: %+v", r) + } + } + + if err := s.ClearReaderMarks(other); err != nil { + t.Fatalf("ClearReaderMarks: %v", err) + } + if err := s.ClearReaderMarks(other + 9999); err != nil { + t.Fatalf("ClearReaderMarks(unknown id): %v", err) + } + readers, err = s.Readers() + if err != nil { + t.Fatalf("Readers: %v", err) + } + for _, r := range readers { + if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() { + t.Fatalf("reader %d not cleared: %+v", r.ID, r) + } + } +} + // Two Readers on one Series: one series row, two independent progresses. The // second Reader starts at zero however far the first has read, and the shared // row is still due exactly once.