From cdd4eb7c25f5ad18c31b206ca0d6f8d135b5b149 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sun, 16 Aug 2026 14:17:10 +0700
Subject: [PATCH 1/7] store: Sighting counters and the owner's clear control
(#102)
The owner's page (issue #102) shows each Reader's Sighting record and offers
one action to wipe it. The counters ship before the Sighting feature that
moves them (issue #103) on purpose: the remedy for a false mark must exist
before marks can be made, or the first broken adapter is fixed with SQL
against production.
- 0010 adds sighting_agreements / sighting_disagreements, both zero-defaulted,
so every existing Reader reads as trusted.
- ReaderSummary carries both, plus Blocked() against SightingDisagreementLimit,
so the page states the verdict rather than making the owner derive it.
- ClearReaderMarks zeroes one Reader's pair.
---
.../migrations/0010_reader_sightings.sql | 6 ++
backend/internal/store/store.go | 41 ++++++++--
backend/internal/store/store_test.go | 80 +++++++++++++++++++
3 files changed, 121 insertions(+), 6 deletions(-)
create mode 100644 backend/internal/store/migrations/0010_reader_sightings.sql
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.
--
2.52.0
From d555f545291fa07658207229b551025c41e4b9c8 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sun, 16 Aug 2026 14:17:22 +0700
Subject: [PATCH 2/7] latest: report each Poll Lane's last pass to the owner's
page (#102)
A Lane's pace, its refusal backoff and whether its Site needs the browser were
only ever visible in the log. The admin page (issue #102) has to state them, so
the Poller keeps one snapshot per Lane.
- LaneState/Status (status.go) is the page's view of a Lane: due, gap, clamped,
refusing, browser.
- runOnce records a snapshot on every return path, including the pass that
refuses, so a cooling Lane does not read as one that never ran.
- Refusing is derived at snapshot read time from the backoff, not stored: a
Lane that cooled down between passes must report false without a new pass.
- LaneStatus reports only Sites that have completed a pass, so a fresh restart
renders "no data yet" instead of confident zeroes.
---
backend/internal/latest/poller.go | 10 ++++
backend/internal/latest/poller_test.go | 67 ++++++++++++++++++++++++++
backend/internal/latest/status.go | 63 ++++++++++++++++++++++++
3 files changed, 140 insertions(+)
create mode 100644 backend/internal/latest/status.go
diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go
index 433b6f8..e0dbe12 100644
--- a/backend/internal/latest/poller.go
+++ b/backend/internal/latest/poller.go
@@ -57,6 +57,10 @@ type Poller struct {
mu sync.Mutex
refuseUntil map[string]time.Time
browserDownAt time.Time
+ // laneStates is the owner's page snapshot of each Lane's last pass
+ // (issue #102), keyed by Site. Guarded by mu; a Site appears only after
+ // its first pass, so a restart renders "no data yet" rather than zeroes.
+ laneStates map[string]LaneState
// coverWG tracks in-flight cover work. Covers heal in the background so a
// slow cover host cannot delay the next Series-page Poll; tests join it
// before asserting on cover fetches.
@@ -218,6 +222,10 @@ func (p *Poller) runOnce(ctx context.Context) {
// production Lane's rate limit; the deterministic test entry runs back to back.
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
now := p.Now()
+ // Snapshot this pass for the owner's page (issue #102). Recorded on every
+ // return path, with the figures filled in where the pass computes them.
+ st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
+ defer func() { p.recordLaneState(st) }()
if until := p.refusalBackoff(name); now.Before(until) {
// Cooling down after a refusal: do not attempt this Site at all.
return until.Sub(now)
@@ -246,6 +254,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
log.Printf("latest poll %s: due query: %v", name, err)
return defaultGap
}
+ st.Due = len(due)
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
// browser): waking it for a single Poll would cost a challenge solve
@@ -268,6 +277,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
return defaultGap
}
gap, clamped := effectiveGap(s, eligible)
+ st.Gap, st.Clamped = gap, clamped
if clamped {
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
}
diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go
index 4f9d8ce..7cff35e 100644
--- a/backend/internal/latest/poller_test.go
+++ b/backend/internal/latest/poller_test.go
@@ -1580,3 +1580,70 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
}
}
+
+// The owner's admin page (issue #102) reads Lane state out of the poller.
+// Before any pass the snapshot is empty — a restart must render "no data
+// yet", not zeroes — and each pass records what it saw: the frozen clock,
+// the due count and the pace, with refusal backoff derived at snapshot time.
+func TestLaneStatus(t *testing.T) {
+ s, _ := newTestStore(t)
+ now := time.UnixMilli(5_000_000)
+ p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
+
+ if st := p.LaneStatus(); len(st.Lanes) != 0 {
+ t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
+ } else if st.BrowserConfigured || st.BrowserReachable {
+ t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
+ }
+
+ seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
+ // Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
+ browser := &fakeFetcher{status: 403}
+ p.BrowserFetch = browser
+ for i := range 2 {
+ key := fmt.Sprintf("kagane:s%d", i)
+ seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
+ }
+ p.runOnce(context.Background())
+
+ st := p.LaneStatus()
+ var asura, kagane LaneState
+ for _, lane := range st.Lanes {
+ switch lane.Site {
+ case "asura":
+ asura = lane
+ case "kagane":
+ kagane = lane
+ }
+ }
+ if st.Lanes[0].Site != "asura" {
+ t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
+ }
+ if asura.Site == "" {
+ t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
+ }
+ if !asura.LastRun.Equal(now) {
+ t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
+ }
+ if asura.Due != 1 {
+ t.Fatalf("asura Due = %d, want 1", asura.Due)
+ }
+ if asura.Gap == 0 {
+ t.Fatal("asura Gap = 0, want the Lane's pace")
+ }
+ if asura.Browser || asura.Refusing {
+ t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
+ }
+ if !kagane.Browser || !kagane.Refusing {
+ t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
+ }
+ if !st.BrowserConfigured || !st.BrowserReachable {
+ t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
+ }
+
+ // A lost sidecar reads as unreachable for the same window the Lanes skip.
+ p.setBrowserDown(now)
+ if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
+ t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
+ }
+}
diff --git a/backend/internal/latest/status.go b/backend/internal/latest/status.go
new file mode 100644
index 0000000..da8b417
--- /dev/null
+++ b/backend/internal/latest/status.go
@@ -0,0 +1,63 @@
+package latest
+
+import "time"
+
+// LaneState is the administrative page's view of one Poll Lane (issue #102):
+// what the Lane's last completed pass saw. Due and Gap are filled in as the
+// pass computes them, so a pass that returned before reaching a figure (Lane
+// in refusal backoff, no fetcher) records a zero in its place.
+type LaneState struct {
+ Site string
+ Due int
+ LastRun time.Time
+ Gap time.Duration
+ Clamped bool
+ Refusing bool
+ Browser bool
+}
+
+// Status is the owner's page snapshot of the whole poller (issue #102).
+type Status struct {
+ Lanes []LaneState
+ BrowserConfigured bool
+ BrowserReachable bool
+}
+
+// LaneStatus returns a copy of the poller's Lane state for the owner's page.
+// Only Sites that have completed a pass appear — a restart therefore renders
+// "no data yet" instead of confident zeroes — in the same order Run iterates.
+// Refusing is derived at snapshot time from the refusal backoff, not stored,
+// so a Lane that cooled down between passes reports false without a new pass.
+// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
+// within the refuseBackoff window since its last loss.
+func (p *Poller) LaneStatus() Status {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ lanes := make([]LaneState, 0, len(p.laneStates))
+ now := p.Now()
+ for _, name := range laneNames() {
+ st, ok := p.laneStates[name]
+ if !ok {
+ continue
+ }
+ st.Refusing = now.Before(p.refuseUntil[name])
+ lanes = append(lanes, st)
+ }
+ configured := p.BrowserFetch != nil
+ reachable := configured
+ if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
+ reachable = false
+ }
+ return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
+}
+
+// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
+// from runLanePass so every return path records, even a pass that refused.
+func (p *Poller) recordLaneState(st LaneState) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.laneStates == nil {
+ p.laneStates = make(map[string]LaneState)
+ }
+ p.laneStates[st.Site] = st
+}
--
2.52.0
From e69da2a99bb8c4036dc3d9faebd9a55bcf61135e Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sun, 16 Aug 2026 14:17:30 +0700
Subject: [PATCH 3/7] web: owner-only /admin with the Reader roster and Poll
Lane status (#102)
The only operational surface was /healthz and a fold-out roster inside the
owner's own reading page. This gives the owner a page: two sections of facts on
the same measured sheet, no cards.
- admin.go holds every route that reaches past the acting Reader, listed once
in adminRoutes() and wrapped in requireOwner at registration - a missing gate
is visible in the route list rather than hidden inside a handler. A non-owner
gets 404, the same answer revoke already gave.
- Lane figures arrive through the LaneReporter seam, so the page reads the
running poller rather than a table. newRouter converts a nil *Poller to a nil
interface: a typed nil would make the page claim a poller exists.
- The roster moves out of the reading page and gains the Sighting counters, the
blocked verdict and Clear marks. Clearing restores a privilege, so it is a
plain ghost button; --danger stays with revocation.
- --patina is the page's one accent, held at the weight of the other action
accents. Neither --ember (new chapter) nor --danger (destruction) is borrowed
for system health.
---
backend/internal/web/admin.go | 229 ++++++++++++++++++++
backend/internal/web/static/style.css | 56 ++++-
backend/internal/web/templates/admin.html | 36 +++
backend/internal/web/templates/app.html | 6 +-
backend/internal/web/templates/lanes.html | 36 +++
backend/internal/web/templates/readers.html | 53 +++--
backend/internal/web/web.go | 74 ++-----
backend/main.go | 27 ++-
8 files changed, 430 insertions(+), 87 deletions(-)
create mode 100644 backend/internal/web/admin.go
create mode 100644 backend/internal/web/templates/admin.html
create mode 100644 backend/internal/web/templates/lanes.html
diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go
new file mode 100644
index 0000000..df5bd4c
--- /dev/null
+++ b/backend/internal/web/admin.go
@@ -0,0 +1,229 @@
+package web
+
+import (
+ "log"
+ "net/http"
+ "strconv"
+ "time"
+
+ "bookmarkmanager/backend/internal/latest"
+ "bookmarkmanager/backend/internal/store"
+)
+
+// LaneReporter is the administrative page's whole window onto the running
+// poller: one snapshot of Poll Lane state, copied out of memory on request.
+// The Poller satisfies it in production and a fake with fixed values satisfies
+// it in tests, so the page's tests need neither a poller nor a Site.
+type LaneReporter interface {
+ LaneStatus() latest.Status
+}
+
+// adminView is what the administrative page and the roster fragment receive.
+type adminView struct {
+ Readers []store.ReaderSummary
+ // OwnerID travels with the roster so it can tell the owner's own row from
+ // the Readers they may act on.
+ OwnerID int64
+ Lanes lanesView
+}
+
+// lanesView is the Lane status block: one row per Site that has run, plus the
+// browser fact, which is shared by the three browser Sites rather than held
+// once per Site.
+type lanesView struct {
+ Rows []laneRow
+ BrowserConfigured bool
+ BrowserReachable bool
+}
+
+// laneRow is one Lane formatted for reading rather than for arithmetic: the
+// template renders strings and flags, and every judgement about what they mean
+// is made here.
+type laneRow struct {
+ Site string
+ Due int
+ Ran string
+ Gap string
+ Clamped bool
+ Refusing bool
+ // BrowserLost marks a Lane whose pages can only be read through the
+ // sidecar while the sidecar is unreachable — including the case where none
+ // is configured, which stops those Series just as completely.
+ BrowserLost bool
+ // Attention is the one flag the template colours on, so an unhealthy Lane
+ // is found at a glance rather than read for.
+ Attention bool
+}
+
+// adminRoute pairs a route pattern with its handler so the route list and the
+// gate cannot drift apart.
+type adminRoute struct {
+ pattern string
+ handler http.HandlerFunc
+}
+
+// adminRoutes is every route that reaches past the acting Reader. Register
+// wraps each one in requireOwner, so a new administrative route is gated by
+// being listed here rather than by remembering to write a check inside it.
+func (h *Handler) adminRoutes() []adminRoute {
+ return []adminRoute{
+ {"GET /admin", h.admin},
+ {"GET /ui/admin/lanes", h.uiLanes},
+ {"POST /readers/{id}/revoke", h.revokeReaderSessions},
+ {"POST /readers/{id}/clear-marks", h.clearReaderMarks},
+ }
+}
+
+// AdminPatterns names every administrative route, so one test can prove the
+// owner gate covers all of them rather than one test per route. The receiver is
+// nil because only the patterns are read; the bound handlers are never called.
+func AdminPatterns() []string {
+ routes := (*Handler)(nil).adminRoutes()
+ out := make([]string, 0, len(routes))
+ for _, rt := range routes {
+ out = append(out, rt.pattern)
+ }
+ return out
+}
+
+// requireOwner is the owner test, in one place, layered on the session gate: no
+// session is still 401, and a signed-in Reader who is not the owner gets 404
+// rather than 403 — a refusal that confirms the address exists is a refusal
+// that helps whoever is probing for it.
+func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
+ return h.requireSession(func(w http.ResponseWriter, r *http.Request) {
+ if readerOf(r) != h.store.OwnerID() {
+ http.NotFound(w, r)
+ return
+ }
+ next(w, r)
+ })
+}
+
+// admin renders the owner's page: the Reader roster and Poll Lane status.
+func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
+ readers, err := h.store.Readers()
+ if err != nil {
+ log.Printf("admin: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "admin", adminView{
+ Readers: readers,
+ OwnerID: h.store.OwnerID(),
+ Lanes: h.lanesView(),
+ })
+}
+
+// uiLanes answers the status block's own refresh. Only the block refreshes on a
+// timer; the roster re-renders after an action, as it always has.
+func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
+ h.render(w, http.StatusOK, "lanes", h.lanesView())
+}
+
+// lanesView copies the poller's snapshot into display form. A nil reporter (no
+// poller running) and a poller no Lane has reported to yet are the same thing
+// to the page: no data, which it must say rather than draw as confident zeroes
+// — an empty page a few seconds after a restart must not read as a stopped one.
+func (h *Handler) lanesView() lanesView {
+ if h.lanes == nil {
+ return lanesView{}
+ }
+ snap := h.lanes.LaneStatus()
+ v := lanesView{
+ Rows: make([]laneRow, 0, len(snap.Lanes)),
+ BrowserConfigured: snap.BrowserConfigured,
+ BrowserReachable: snap.BrowserReachable,
+ }
+ now := time.Now()
+ for _, l := range snap.Lanes {
+ lost := l.Browser && !snap.BrowserReachable
+ v.Rows = append(v.Rows, laneRow{
+ Site: l.Site,
+ Due: l.Due,
+ Ran: since(now, l.LastRun),
+ Gap: l.Gap.Truncate(time.Second).String(),
+ Clamped: l.Clamped,
+ Refusing: l.Refusing,
+ BrowserLost: lost,
+ Attention: l.Clamped || l.Refusing || lost,
+ })
+ }
+ return v
+}
+
+// since formats how long ago a Lane last ran, at second resolution: the block
+// refreshes every thirty seconds, so anything finer is noise the owner would
+// have to ignore.
+func since(now, then time.Time) string {
+ d := now.Sub(then).Truncate(time.Second)
+ if d < time.Second {
+ return "just now"
+ }
+ return d.String() + " ago"
+}
+
+// revokeReaderSessions logs one Reader out of every browser they are signed in
+// on. The owner gate is the route's, not this handler's.
+func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
+ target, ok := readerPathID(w, r)
+ if !ok {
+ return
+ }
+ // The owner is not one of the Readers this endpoint reaches: revoking
+ // themselves would sign out the browser making the request, which is what
+ // logout is for. The roster hides the button; this refuses the hand-rolled
+ // POST behind it.
+ if target == h.store.OwnerID() {
+ http.NotFound(w, r)
+ return
+ }
+ if err := h.store.DeleteReaderSessions(target); err != nil {
+ log.Printf("revoke sessions: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.renderRoster(w, "revoke sessions")
+}
+
+// clearReaderMarks zeroes one Reader's Sighting counters. The guard those
+// counters feed has one known false positive — a Site changing its page shape
+// makes a correct adapter read a wrong high number and marks every honest
+// Reader of that Site at once (issue #103) — and this is its remedy. It
+// restores a privilege rather than destroying anything, so the control is
+// confirmed but never wears the destruction accent.
+func (h *Handler) clearReaderMarks(w http.ResponseWriter, r *http.Request) {
+ target, ok := readerPathID(w, r)
+ if !ok {
+ return
+ }
+ if err := h.store.ClearReaderMarks(target); err != nil {
+ log.Printf("clear marks: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.renderRoster(w, "clear marks")
+}
+
+// readerPathID reads the Reader a route names, answering the request itself
+// when there is nobody to act on.
+func readerPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ http.Error(w, "bad reader id", http.StatusBadRequest)
+ return 0, false
+ }
+ return id, true
+}
+
+// renderRoster answers an action with the whole roster, so the counts and marks
+// it shows cannot describe the state before the tap.
+func (h *Handler) renderRoster(w http.ResponseWriter, what string) {
+ readers, err := h.store.Readers()
+ if err != nil {
+ log.Printf("%s: %v", what, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "readers", adminView{Readers: readers, OwnerID: h.store.OwnerID()})
+}
diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css
index fbf07ec..9a091b0 100644
--- a/backend/internal/web/static/style.css
+++ b/backend/internal/web/static/style.css
@@ -87,6 +87,10 @@
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
+ /* A Lane needing attention: the admin page's only accent, held at the same
+ muted weight as --brass so it never competes with ember. Neither ember
+ (new chapter) nor danger (destruction) may say "system unhealthy". */
+ --patina: #b08a4a;
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
@@ -146,6 +150,7 @@
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
+ --patina: #7a5a1e;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
@@ -290,9 +295,21 @@ button { cursor: pointer; }
letter-spacing: .04em;
}
-/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
-.readerlist { margin: 0; padding: 0; list-style: none; }
-.readerlist li {
+/* ---- admin page: two sections on the same measured sheet, no cards ----
+ The reading page is a list of series; this is a list of facts. Both are
+ sheets of hairline-separated rows, so the roster keeps the shape it had as
+ a fold-out and the Lane block copies it. */
+.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); }
+.readers h2, .lanes h2 {
+ margin: 0;
+ padding: 8px 0;
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .2em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; }
+.readerlist li, .lanelist li {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -300,7 +317,8 @@ button { cursor: pointer; }
min-height: 44px;
border-top: 1px solid var(--rule);
}
-.readerlist form { margin: 0 0 0 auto; }
+.reader-actions { display: flex; gap: 18px; margin-left: auto; }
+.readerlist form { margin: 0; }
.reader-id {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .04em;
@@ -312,6 +330,30 @@ button { cursor: pointer; }
text-transform: uppercase;
color: var(--mute);
}
+.reader-sightings, .lane-fact {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--mute-2);
+}
+/* Two states the owner is meant to find rather than read for: a Reader whose
+ reports no longer defer a Poll, and a Lane that is not keeping its promise.
+ Both wear --patina — never ember, which means one thing, and never danger,
+ which is destruction. */
+.reader-blocked, .lane-mark {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ color: var(--patina);
+}
+.lane-site {
+ font: 400 19px/1.2 var(--font-display);
+ color: var(--paper-dim);
+}
+/* The whole row leans patina when the Lane needs attention, so the scan is one
+ pass down the left edge rather than a read of every mark. */
+.lanelist li.attention .lane-site { color: var(--patina); }
+.lane-browser { padding: 12px 0 0; }
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
the new-chapter signal. */
.ghost.danger { color: var(--danger); }
@@ -600,6 +642,9 @@ button { cursor: pointer; }
box-shadow: inset 0 -2px 0 var(--ember);
}
.topbar form { margin-left: 18px; }
+/* The admin page's topbar has no switch to fill the middle, so its back link
+ keeps company with Log out at the right edge instead of floating centre. */
+.topbar .back { margin-left: auto; }
/* At phone width brand + switch + Log out do not fit on one line, so the
switch takes its own row under the wordmark rather than pushing Log out
off-screen. */
@@ -609,6 +654,9 @@ button { cursor: pointer; }
.libswitch { order: 3; margin-left: 0; }
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
.topbar form { margin-left: 12px; }
+ /* The admin page has no switch to take the second row, so its brand claims
+ the first outright and the back link keeps Log out company below. */
+ .topbar:has(.back) .brand { flex: 1 1 100%; }
}
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html
new file mode 100644
index 0000000..c1fdaae
--- /dev/null
+++ b/backend/internal/web/templates/admin.html
@@ -0,0 +1,36 @@
+{{/* The owner's administrative page: everything that reaches past one Reader,
+ at its own address so it can be bookmarked rather than hunted for inside
+ the reading page. Owner-only at route registration (requireOwner), which
+ is why nothing in here re-tests who is asking. */}}
+{{define "admin"}}
+
+
+
+
+
+
+ BookmarkManager — Admin
+
+
+
+
+
+
+
+
+
{{template "mark" .}}BookmarkManager
+ {{/* Back to the library, no switch: this page belongs to neither library,
+ and the ember-lit switch says which library you are reading. */}}
+ Library
+
+
+
+ {{template "lanes" .Lanes}}
+
+ {{template "readers" .}}
+
+
+
+{{end}}
diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html
index ae6586a..d761b95 100644
--- a/backend/internal/web/templates/app.html
+++ b/backend/internal/web/templates/app.html
@@ -28,6 +28,10 @@
Novels
+ {{/* The owner's only difference on this page: a link out to the
+ administrative one. It sits beside Log out rather than in the library
+ switch — that switch says which library, not which page. */}}
+ {{if .Owner}}Admin{{end}}
@@ -76,8 +80,6 @@
{{template "setup" .}}
- {{if .Owner}}{{template "readers" .}}{{end}}
-
{{template "keyrow" .}}
{{template "recent" .}}
diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html
new file mode 100644
index 0000000..79a70fb
--- /dev/null
+++ b/backend/internal/web/templates/lanes.html
@@ -0,0 +1,36 @@
+{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
+ watched rather than sampled by reloading. The refresh is one attribute on
+ the fragment root and the endpoint answers with this same fragment, so the
+ swap replaces the element that asked for it.
+
+ Every figure here is read out of the running poller, never out of a table:
+ a Site absent from Rows has not completed a pass since the last restart,
+ which the empty state must say — zeroes would read as a stopped Lane. */}}
+{{define "lanes"}}
+
+
Poll Lanes
+ {{if .Rows}}
+
+ {{range .Rows}}
+
+ {{.Site}}
+ {{.Due}} due
+ ran {{.Ran}}
+ gap {{.Gap}}
+ {{if .Clamped}}gap at floor{{end}}
+ {{if .Refusing}}refusing{{end}}
+ {{if .BrowserLost}}no browser{{end}}
+
+ {{end}}
+
+ {{else}}
+
No data yet — no Lane has completed a pass since the
+ backend started.
+ {{end}}
+
Browser sidecar:
+ {{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
+ pages are not fetched through it{{else if .BrowserReachable}}reachable
+ {{else}}unreachable{{end}}.
+
+{{end}}
diff --git a/backend/internal/web/templates/readers.html b/backend/internal/web/templates/readers.html
index 85b844c..0cef186 100644
--- a/backend/internal/web/templates/readers.html
+++ b/backend/internal/web/templates/readers.html
@@ -1,29 +1,48 @@
-{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
- and re-rendered whole as the response to a revocation so the session
- counts cannot describe the state before the tap. Revocation is
- confirm-gated: it signs someone out of every device at once. */}}
+{{/* The Reader roster, on the owner's administrative page. Re-rendered whole
+ as the response to an action so the counts and marks it shows cannot
+ describe the state before the tap. Both actions are confirm-gated: one
+ signs a Reader out of every device at once, the other wipes a record.
+
+ Owner-only at route registration, so nothing here re-tests who is asking. */}}
{{define "readers"}}
-
- Readers
+
+
Readers
Everyone who has signed in through Discord. Revoking
signs a Reader out of every device; their library and bookmarks are
- untouched, and they can sign in again.
+ untouched, and they can sign in again. The Sighting counters record how
+ often a later Poll confirmed or contradicted what that Reader's browser
+ reported; enough contradictions stop their reports deferring a Poll, and
+ clearing the marks gives that back.
{{range .Readers}}
{{.DiscordID}}{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}
- {{/* The owner's own row never offers Revoke: it is the one row where the
- button would sign the tapping browser out, and the endpoint refuses
- it anyway. Logout is the deliberate way to do that. */}}
- {{if and .Sessions (ne .ID $.OwnerID)}}
-
- {{end}}
+ {{.Agreements}} confirmed / {{.Disagreements}} contradicted
+ {{/* Blocked is spelled out rather than left to be worked out from two
+ numbers and a threshold. */}}
+ {{if .Blocked}}deferral blocked{{end}}
+
+ {{/* Clearing restores a privilege, so it is a plain ghost button —
+ the destruction accent belongs to revocation alone. It is offered
+ on every row, including one reading zero: the remedy must be
+ findable before the counters climb, not after. */}}
+
+ {{/* The owner's own row never offers Revoke: it is the one row where the
+ button would sign the tapping browser out, and the endpoint refuses
+ it anyway. Logout is the deliberate way to do that. */}}
+ {{if and .Sessions (ne .ID $.OwnerID)}}
+
+ {{end}}
+
{{end}}
-
+
{{end}}
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go
index 4c3c753..e1f3a69 100644
--- a/backend/internal/web/web.go
+++ b/backend/internal/web/web.go
@@ -50,6 +50,9 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client
+ // lanes is the Poll Lane snapshot source the administrative page reads.
+ // Nil is a running deployment with no poller, not a bug.
+ lanes LaneReporter
}
// listView is what every list-rendering template receives.
@@ -77,14 +80,9 @@ type listView struct {
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
- // Owner marks the acting Reader as the deployment's owner, which unlocks
- // the Readers panel. Nothing else in the UI differs.
+ // Owner marks the acting Reader as the deployment's owner, which offers
+ // the link to the administrative page. Nothing else in the UI differs.
Owner bool
- // Readers is the owner's roster, populated only for the owner's own page
- // render and the revocation fragment. OwnerID travels with it so the roster
- // can tell the owner's own row apart from the Readers they may revoke.
- Readers []store.ReaderSummary
- OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -111,7 +109,10 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
-func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
+//
+// lanes is the administrative page's window onto the running Poller; nil means
+// nothing is polling, which the page reports rather than hides.
+func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
@@ -126,6 +127,7 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(),
limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout},
+ lanes: lanes,
}, nil
}
@@ -149,9 +151,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
-
- // Owner-only: the one place the UI crosses the Reader boundary.
- mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
+ // Owner-only: every route that reaches past the acting Reader is gated in
+ // one place, so a missing gate is visible in the route list.
+ for _, rt := range h.adminRoutes() {
+ mux.HandleFunc(rt.pattern, h.requireOwner(rt.handler))
+ }
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -240,14 +244,9 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- if readerID == h.store.OwnerID() {
- view.Owner, view.OwnerID = true, readerID
- if view.Readers, err = h.store.Readers(); err != nil {
- log.Printf("index readers: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- }
+ // The owner's page differs only by the link to the administrative page:
+ // the roster lives there now, so the page read every day is only reading.
+ view.Owner = readerID == h.store.OwnerID()
h.render(w, http.StatusOK, "app", view)
}
@@ -618,40 +617,3 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
-
-// revokeReaderSessions logs one Reader out of every browser they are signed
-// in on. Owner-only: it reaches across the Reader boundary every other handler
-// respects, so the guard is a comparison against the seeded owner rather than
-// a role a Reader could acquire. A non-owner gets 404 — the panel does not
-// exist for them, so neither should the endpoint.
-func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
- if readerOf(r) != h.store.OwnerID() {
- http.NotFound(w, r)
- return
- }
- target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
- if err != nil {
- http.Error(w, "bad reader id", http.StatusBadRequest)
- return
- }
- // The owner is not one of the Readers this endpoint reaches: revoking
- // themselves would sign out the browser making the request, which is what
- // logout is for. The roster hides the button; this refuses the hand-rolled
- // POST behind it.
- if target == h.store.OwnerID() {
- http.NotFound(w, r)
- return
- }
- if err := h.store.DeleteReaderSessions(target); err != nil {
- log.Printf("revoke sessions: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- readers, err := h.store.Readers()
- if err != nil {
- log.Printf("revoke sessions: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
-}
diff --git a/backend/main.go b/backend/main.go
index 74f16cd..bebff8d 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -129,7 +129,10 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
-func newRouter(s *store.Store, cfg Config) http.Handler {
+//
+// lanes may be nil — polling disabled, or its client could not be built. The
+// admin page reports that rather than pretending Lanes exist.
+func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
mux := http.NewServeMux()
h := &api.Handler{Store: s}
mux.HandleFunc("GET /healthz", api.Healthz)
@@ -160,7 +163,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
- cfg.UserscriptPath, cfg.NovelUserscriptPath)
+ cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -275,11 +278,17 @@ func main() {
}
s.OnSeriesCreated = acq.Acquire
}
- startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
+ // A nil *Poller must not become a non-nil interface holding a nil pointer:
+ // the admin page tests the reporter for nil to decide whether anything is
+ // polling at all.
+ var lanes web.LaneReporter
+ if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
+ lanes = poller
+ }
srv := &http.Server{
Addr: ":" + cfg.Port,
- Handler: newRouter(s, cfg),
+ Handler: newRouter(s, cfg, lanes),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -326,16 +335,17 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
// startLatestPoller launches the background poller unless it is disabled or its
// HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter
-// tracking, which is exactly how it behaved before.
-func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
+// tracking, which is exactly how it behaved before. It returns the running
+// Poller, or nil when there is none — the admin page's Lane status reads it.
+func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")
- return
+ return nil
}
f, err := latest.NewTLSFetcher()
if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
- return
+ return nil
}
// Nil browser: sites behind a JavaScript challenge are simply not polled,
// and their latest_chapter comes from the userscript alone — which is how
@@ -343,4 +353,5 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
p := newLatestPoller(s, cfg, f, browser)
go p.Run(ctx)
+ return p
}
--
2.52.0
From 0b946ee2c4b194252ccc17763beac0d1641f144d Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sun, 16 Aug 2026 14:17:36 +0700
Subject: [PATCH 4/7] test, docs: admin page gate tests and the page's written
rules (#102)
- web_test.go walks web.AdminPatterns() rather than naming routes by hand, so a
new administrative route that forgets requireOwner fails the gate test
instead of shipping open.
- The harnesses take a LaneReporter; a fake one keeps the page's tests free of
a poller and a Site.
- backend/AGENTS.md records the adminRoutes/requireOwner rule and the nil-poller
trap; design-system.md records --patina and the admin page's shape.
---
backend/AGENTS.md | 16 ++-
backend/api_test.go | 8 +-
backend/cover_test.go | 2 +-
backend/reader_credential_test.go | 6 +-
backend/web_test.go | 197 +++++++++++++++++++++++++++---
docs/design-system.md | 20 ++-
6 files changed, 218 insertions(+), 31 deletions(-)
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index f365f31..8315b44 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -211,6 +211,16 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
- Owner-only `POST /readers/{id}/revoke` (drops one Reader's session rows and
- re-renders the `readers` panel; 404 for any non-owner) is the only route that
- reaches across Readers.
+- **Owner-only admin page (`internal/web/admin.go`, issue #102):** `GET /admin`
+ carries the Reader roster (sessions, Sighting counters, `POST
+ /readers/{id}/revoke` and `POST /readers/{id}/clear-marks`) and Poll Lane
+ status (`GET /ui/admin/lanes`, self-refreshing every 30s). Every route that
+ reaches past the acting Reader is listed in `adminRoutes()` and wrapped in
+ `requireOwner` at registration — add a route there, not a check inside a
+ handler; `web.AdminPatterns()` is what the gate test walks. A non-owner gets
+ 404, never 403. Lane figures come from the running poller through the
+ `web.LaneReporter` seam (`latest.Poller.LaneStatus`), never from a table: a
+ nil reporter or a Lane that has not finished a pass renders "no data yet"
+ rather than zeroes. `main.newRouter` takes the reporter as an interface and
+ converts a nil `*Poller` to a nil interface — a typed nil would make the page
+ claim a poller exists.
diff --git a/backend/api_test.go b/backend/api_test.go
index 44880d4..d2458db 100644
--- a/backend/api_test.go
+++ b/backend/api_test.go
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
- return newRouter(newTestStore(t), testConfig())
+ return newRouter(newTestStore(t), testConfig(), nil)
}
func newTestStore(t *testing.T) *store.Store {
@@ -599,7 +599,7 @@ func TestLoadConfigDiscord(t *testing.T) {
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
- srv := newRouter(s, testConfig())
+ srv := newRouter(s, testConfig(), nil)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
@@ -637,7 +637,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
- newRouter(s, cfg).ServeHTTP(rr, req)
+ newRouter(s, cfg, nil).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -659,7 +659,7 @@ func TestNovelUserscriptServed(t *testing.T) {
cfg := testConfig()
cfg.NovelUserscriptPath = novelPath
- srv := newRouter(s, cfg)
+ srv := newRouter(s, cfg, nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
diff --git a/backend/cover_test.go b/backend/cover_test.go
index 9c3c93f..77ea269 100644
--- a/backend/cover_test.go
+++ b/backend/cover_test.go
@@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
t.Fatalf("poison row: %v", err)
}
- rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil)
+ rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
if rr.Code == http.StatusOK {
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
}
diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go
index e66970c..03d2a85 100644
--- a/backend/reader_credential_test.go
+++ b/backend/reader_credential_test.go
@@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request {
// A refused credential is refused however plausible it looks: only a hash the
// readers table holds authenticates anything.
func TestUnknownCredentialRejected(t *testing.T) {
- srv := newRouter(newTestStore(t), testConfig())
+ srv := newRouter(newTestStore(t), testConfig(), nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
func TestPerReaderIsolation(t *testing.T) {
s := newTestStore(t)
registerReader(t, s, "other-reader")
- srv := newRouter(s, testConfig())
+ srv := newRouter(s, testConfig(), nil)
ownerKey := "asura:solo"
putBookmark(t, srv, ownerKey, store.Bookmark{
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
}
cfg := testConfig()
cfg.UserscriptPath = path
- srv := newRouter(s, cfg)
+ srv := newRouter(s, cfg, nil)
oldCred := ownerCredential()
rr := httptest.NewRecorder()
diff --git a/backend/web_test.go b/backend/web_test.go
index eb3c704..4e0b9e8 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -15,6 +15,7 @@ import (
"testing"
"time"
+ "bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
@@ -26,11 +27,17 @@ import (
const testOwnerID = "owner-snowflake"
// newWebTestServer returns the full router plus the store behind it, so tests
-// can seed rows and assert on what the handlers wrote back.
-func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
+// can seed rows and assert on what the handlers wrote back. An optional lane
+// reporter stands in for the running poller; omitted means none is running,
+// which is what every test that is not about the admin page wants.
+func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
- return newRouter(st, cfg), st
+ var reporter web.LaneReporter
+ if len(lanes) > 0 {
+ reporter = lanes[0]
+ }
+ return newRouter(st, cfg, reporter), st
}
// sessionCookie mints a live session row for the owner and returns the cookie
@@ -141,12 +148,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
// oauthWebTestServer returns the full router, its store, and a Discord stub
// wired as the configured API — the starting point for sign-in tests.
-func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
+func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
- router, st := newWebTestServer(t, cfg)
+ router, st := newWebTestServer(t, cfg, lanes...)
return router, st, stub
}
@@ -410,7 +417,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st := newTestStore(t)
- router := newRouter(st, cfg)
+ router := newRouter(st, cfg, nil)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
@@ -626,24 +633,52 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
}
}
-// The owner's own page carries the roster; nobody else's does.
-func TestOwnerSeesReadersPanel(t *testing.T) {
+// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
+// page's tests need neither a poller nor a Site.
+type fakeLanes struct{ status latest.Status }
+
+func (f fakeLanes) LaneStatus() latest.Status { return f.status }
+
+// The roster moved off the reading page onto its own address: the owner gets a
+// link, everyone else gets nothing, and the page itself lists every Reader with
+// the counters and the two controls.
+func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
- signInCookie(t, router)
+ theirCookie := signInCookie(t, router)
+ ownerCookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
- req.AddCookie(sessionCookie(t, st))
+ req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
- if !strings.Contains(body, `id="readers"`) {
- t.Fatal("the owner's page lacks the Readers panel")
+ if strings.Contains(body, `id="readers"`) {
+ t.Error("the reading page still carries the roster; it belongs on /admin")
}
- if !strings.Contains(body, testOwnerID) {
- t.Fatalf("the roster does not list the registered Reader:\n%s", body)
+ if !strings.Contains(body, `href="/admin"`) {
+ t.Error("the owner's reading page offers no link to the admin page")
}
- if !strings.Contains(body, "Revoke sessions") {
- t.Fatal("the roster offers no revocation control for a signed-in Reader")
+
+ req = httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(theirCookie)
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if strings.Contains(rr.Body.String(), `href="/admin"`) {
+ t.Error("a non-owner was offered the admin link")
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(ownerCookie)
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("GET /admin status = %d, want 200", rr.Code)
+ }
+ body = rr.Body.String()
+ for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("admin page lacks %q:\n%s", want, body)
+ }
}
// Exactly one revocable row: the other Reader's. The owner's own row carries
// the same session count and no button.
@@ -652,6 +687,136 @@ func TestOwnerSeesReadersPanel(t *testing.T) {
}
}
+// Every administrative route is gated the same way, so the test walks the list
+// the router registers rather than naming routes by hand: no session is 401,
+// a signed-in non-owner is 404, and the address is not confirmed to either.
+func TestAdminRoutesAreOwnerOnly(t *testing.T) {
+ router, st, _ := oauthWebTestServer(t)
+ theirCookie := signInCookie(t, router)
+ ownerCookie := sessionCookie(t, st)
+ target := strconv.FormatInt(st.OwnerID(), 10)
+
+ patterns := web.AdminPatterns()
+ if len(patterns) == 0 {
+ t.Fatal("no administrative routes to test")
+ }
+ for _, pattern := range patterns {
+ method, path, ok := strings.Cut(pattern, " ")
+ if !ok {
+ t.Fatalf("route pattern %q has no method", pattern)
+ }
+ path = strings.Replace(path, "{id}", target, 1)
+
+ for _, tc := range []struct {
+ name string
+ cookie *http.Cookie
+ want int
+ }{
+ {"no session", nil, http.StatusUnauthorized},
+ {"non-owner", theirCookie, http.StatusNotFound},
+ } {
+ req := httptest.NewRequest(method, path, nil)
+ if tc.cookie != nil {
+ req.AddCookie(tc.cookie)
+ }
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != tc.want {
+ t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
+ }
+ }
+
+ req := httptest.NewRequest(method, path, nil)
+ req.AddCookie(ownerCookie)
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code == http.StatusUnauthorized {
+ t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
+ }
+ }
+}
+
+// The Lane block reports what the poller says, and marks the Lanes that need
+// attention — a clamped gap, a refusal, or a Site whose pages can only be read
+// through a sidecar that is not there.
+func TestAdminPageShowsLaneStatus(t *testing.T) {
+ lanes := fakeLanes{latest.Status{
+ Lanes: []latest.LaneState{
+ {Site: "asura", Due: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
+ {Site: "kagane", Due: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
+ {Site: "demonic", Due: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
+ },
+ BrowserConfigured: true,
+ }}
+ router, st, _ := oauthWebTestServer(t, lanes)
+
+ req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+ for _, want := range []string{"asura", "kagane", "12 due", "gap 40s", "ran 1m30s ago", "gap at floor", "no browser", "unreachable"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("lane status lacks %q:\n%s", want, body)
+ }
+ }
+ // Two Lanes need attention: the clamped one and the one cut off from the
+ // sidecar. The healthy Lane must not be marked.
+ if n := strings.Count(body, `class="attention"`); n != 2 {
+ t.Errorf("attention rows = %d, want 2:\n%s", n, body)
+ }
+}
+
+// No poller and a poller that has not finished a pass are the same to the page:
+// it says so rather than drawing zeroes that read as a stopped backend.
+func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
+ router, st, _ := oauthWebTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ body := rr.Body.String()
+ if !strings.Contains(body, "No data yet") {
+ t.Errorf("admin page with no poller does not say so:\n%s", body)
+ }
+ if !strings.Contains(body, "not configured") {
+ t.Errorf("admin page does not report the missing browser sidecar:\n%s", body)
+ }
+}
+
+// Clearing a Reader's marks answers with the whole roster, so the page cannot
+// keep showing the record that was just wiped.
+func TestOwnerClearsReaderMarks(t *testing.T) {
+ router, st, _ := oauthWebTestServer(t)
+ theirCookie := signInCookie(t, router)
+ their, _, err := st.GetSession(theirCookie.Value, time.Now())
+ if err != nil {
+ t.Fatalf("GetSession: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPost,
+ "/readers/"+strconv.FormatInt(their.ReaderID, 10)+"/clear-marks", nil)
+ req.AddCookie(sessionCookie(t, st))
+ rr := httptest.NewRecorder()
+ router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, `id="readers"`) {
+ t.Fatalf("clear marks did not re-render the roster:\n%s", body)
+ }
+ if !strings.Contains(body, "0 confirmed / 0 contradicted") {
+ t.Errorf("roster does not report the cleared counters:\n%s", body)
+ }
+ if strings.Contains(body, "deferral blocked") {
+ t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
+ }
+}
+
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
diff --git a/docs/design-system.md b/docs/design-system.md
index 9f4aeb7..1fca74b 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -10,7 +10,7 @@ Implemented in:
| Surface | Files |
| --- | --- |
-| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
+| Web UI (login, list, card, empty, errors, admin) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,admin,lanes,readers,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
## 1. The one idea
@@ -73,6 +73,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
+| `--patina` | `#b08a4a` | `#7a5a1e` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
@@ -81,9 +82,12 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag |
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
-`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
-one accent per action, so a press says which lane it belongs to, with none of
-them competing with ember. Dark is the default (`color-scheme: dark light`);
+`--slate`/`--moss`/`--clay`/`--brass`/`--patina` are held at the same weight
+deliberately: one accent per meaning, so a press says which lane it belongs to,
+with none of them competing with ember. `--patina` is the admin page's only
+colour — system health is neither a new chapter nor destruction, so it borrows
+neither `--ember` nor `--danger`.
+Dark is the default (`color-scheme: dark light`);
light is a `@media (prefers-color-scheme: light)` override of the same names.
**Any new colour must be added in both branches** — light is not a filter over
dark, the hues are re-tuned.
@@ -140,6 +144,14 @@ Recurring specs (copy these rather than inventing sizes):
main#list article.card … | .empty
```
+The owner's admin page (`admin.html`) is the same sheet with two sections in
+place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
+and no library switch: it belongs to neither library, so its topbar carries a
+plain `.ghost.back` link home. Both sections are eyebrow + hairline-separated
+rows, the shape the roster already had as a fold-out. `.lanes` refreshes itself
+every 30s via `hx-get="/ui/admin/lanes"` with `hx-swap="outerHTML"`; the roster
+re-renders only in answer to an action.
+
**Brand mark**: an inline `