feat: register any guild member as a Reader (#27)
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
This commit is contained in:
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -67,3 +68,13 @@ func (s *Store) DeleteSession(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteReaderSessions revokes every session one Reader holds — the owner's
|
||||
// remedy when a Reader's browser must be logged out everywhere at once. The
|
||||
// next request carrying any of those cookies finds no row and is rejected.
|
||||
func (s *Store) DeleteReaderSessions(readerID int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE reader_id = $1`, readerID); err != nil {
|
||||
return fmt.Errorf("delete sessions for reader %d: %w", readerID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,10 +169,11 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover,
|
||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
|
||||
|
||||
// Owner is the person running the service: the first Reader, and the only one
|
||||
// until registration exists. The seed makes sure exactly one readers row
|
||||
// matches their Discord ID, carrying the SHA-256 of their epoch-0 userscript
|
||||
// credential (derived by internal/token, not the retired global token).
|
||||
// Owner is the person running the service: the first Reader, seeded at startup
|
||||
// so a fresh deployment has a library before anyone logs in. The seed makes
|
||||
// sure exactly one readers row matches their Discord ID, carrying the SHA-256
|
||||
// of their epoch-0 userscript credential (derived by internal/token). Every
|
||||
// other Reader is created by their own first login (EnsureReader).
|
||||
type Owner struct {
|
||||
DiscordID string
|
||||
// TokenHash is the SHA-256 of the epoch-0 credential; the array shape
|
||||
@@ -183,15 +184,14 @@ type Owner struct {
|
||||
// Store is the Postgres-backed bookmark store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
// ownerID is the seeded owner Reader (issue #22). Authentication is still
|
||||
// the single global token, so every request acts as this Reader; the store
|
||||
// methods take the id explicitly so the scoping survives per-Reader auth.
|
||||
// ownerID is the seeded owner Reader (issue #22) — the only Reader with
|
||||
// administrative reach (revoking another Reader's sessions). Every store
|
||||
// method takes a reader id explicitly, so ownership is never implicit.
|
||||
ownerID int64
|
||||
}
|
||||
|
||||
// OwnerID returns the seeded owner Reader's id — the Reader the retired
|
||||
// global token resolves to during the grace window, and the only Reader while
|
||||
// registration is closed.
|
||||
// OwnerID returns the seeded owner Reader's id: the administrator, and the
|
||||
// Reader every pre-registration bookmark belongs to.
|
||||
func (s *Store) OwnerID() int64 { return s.ownerID }
|
||||
|
||||
// ReaderIDForTokenHash resolves the Reader whose stored credential hash
|
||||
@@ -252,6 +252,66 @@ func (s *Store) RotateToken(readerID, expectedEpoch int64, newHash [32]byte) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureReader returns the Reader registered to discordID, creating the row on
|
||||
// first sight. Registration is open to every guild member (issue #27), and the
|
||||
// Discord identity is the only thing that decides which Reader a login is: one
|
||||
// code path serves the first login and every later one, so a returning Reader
|
||||
// can never end up with a second library.
|
||||
//
|
||||
// epochZeroHash is only used for a brand-new row. An existing row keeps its
|
||||
// stored hash untouched, or a login would silently undo a rotation and revive
|
||||
// the credential the Reader rotated away from.
|
||||
func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, error) {
|
||||
var id int64
|
||||
// DO UPDATE rather than DO NOTHING because only an updated row is
|
||||
// returned by RETURNING; assigning the column to itself is the no-op that
|
||||
// makes the existing id come back.
|
||||
err := s.db.QueryRow(`
|
||||
INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2)
|
||||
ON CONFLICT (discord_id) DO UPDATE SET discord_id = readers.discord_id
|
||||
RETURNING id`, discordID, epochZeroHash[:]).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ensure reader: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
type ReaderSummary struct {
|
||||
ID int64
|
||||
DiscordID string
|
||||
// Sessions counts unexpired session rows — what the owner revokes.
|
||||
Sessions int
|
||||
}
|
||||
|
||||
// Readers lists every Reader with their live session count, 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,
|
||||
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
|
||||
ORDER BY r.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query readers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ReaderSummary{}
|
||||
for rows.Next() {
|
||||
var r ReaderSummary
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
|
||||
return nil, fmt.Errorf("scan reader: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
||||
|
||||
// testOwner is the owner every test store seeds. Tests that need a second
|
||||
// reader insert one directly (see secondReader).
|
||||
// reader register one (see secondReader).
|
||||
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
@@ -29,17 +29,14 @@ func newTestStore(t *testing.T) *Store {
|
||||
return store
|
||||
}
|
||||
|
||||
// secondReader inserts an extra reader row and returns its id. The store API
|
||||
// has no reader-creation path yet — the seed is the only one — so tests that
|
||||
// need reader isolation insert directly.
|
||||
// secondReader registers an extra reader through the same path a first login
|
||||
// takes, and returns its id.
|
||||
func secondReader(t *testing.T, s *Store) int64 {
|
||||
t.Helper()
|
||||
hash := sha256.Sum256([]byte("second-token-hash"))
|
||||
var id int64
|
||||
if err := s.db.QueryRow(
|
||||
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
|
||||
"second-"+strconv.FormatInt(time.Now().UnixNano(), 10), hash[:]).Scan(&id); err != nil {
|
||||
t.Fatalf("seed second reader: %v", err)
|
||||
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
|
||||
if err != nil {
|
||||
t.Fatalf("register second reader: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1052,3 +1049,153 @@ func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
|
||||
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration is one code path: the first sight of a Discord identity creates
|
||||
// the Reader, every later one returns the same row. The epoch-0 hash argument
|
||||
// is for creation only — a returning Reader who has rotated must not have that
|
||||
// rotation undone by logging in again.
|
||||
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureReader: %v", err)
|
||||
}
|
||||
if first == s.OwnerID() {
|
||||
t.Fatal("a new Discord identity resolved to the owner Reader")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
|
||||
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
|
||||
}
|
||||
|
||||
rotated := sha256.Sum256([]byte("cred-epoch-1"))
|
||||
if err := s.RotateToken(first, 0, rotated); err != nil {
|
||||
t.Fatalf("RotateToken: %v", err)
|
||||
}
|
||||
|
||||
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureReader: %v", err)
|
||||
}
|
||||
if again != first {
|
||||
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
|
||||
}
|
||||
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
|
||||
t.Fatalf("stale lookup: %v", err)
|
||||
} else if ok {
|
||||
t.Fatal("logging in again revived the pre-rotation credential")
|
||||
}
|
||||
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
|
||||
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
|
||||
}
|
||||
|
||||
// Signing in as the owner's own Discord identity reuses the seeded row
|
||||
// rather than minting a duplicate library.
|
||||
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
|
||||
t.Fatalf("EnsureReader(owner): %v", err)
|
||||
} else if id != s.OwnerID() {
|
||||
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's administration view: who exists and how many live sessions each
|
||||
// holds. Revocation drops all of one Reader's sessions and nobody else's.
|
||||
func TestReadersAndSessionRevocation(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
for _, id := range []string{"own-1", "own-2"} {
|
||||
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession(other): %v", err)
|
||||
}
|
||||
// An expired row must not be counted as a session the owner can revoke.
|
||||
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
|
||||
t.Fatalf("CreateSession(expired): %v", err)
|
||||
}
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
|
||||
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
|
||||
}
|
||||
if readers[0].DiscordID != testOwner.DiscordID {
|
||||
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
|
||||
}
|
||||
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
|
||||
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
|
||||
}
|
||||
|
||||
if err := s.DeleteReaderSessions(other); err != nil {
|
||||
t.Fatalf("DeleteReaderSessions: %v", err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
|
||||
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
|
||||
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
||||
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed owner: %v", err)
|
||||
}
|
||||
theirs, err := s.Upsert(other, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed other: %v", err)
|
||||
}
|
||||
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
|
||||
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
|
||||
}
|
||||
// The shared facts are still shared: the series row it joined to is the
|
||||
// one the first Reader created.
|
||||
if theirs.Title != "Solo Leveling" {
|
||||
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
|
||||
}
|
||||
var series int
|
||||
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
|
||||
t.Fatalf("count series: %v", err)
|
||||
}
|
||||
if series != 1 {
|
||||
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
|
||||
}
|
||||
|
||||
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
|
||||
}
|
||||
|
||||
// One Reader dropping their bookmark leaves the other's intact and the
|
||||
// series still polled.
|
||||
if err := s.Delete(other, "asura:solo"); err != nil {
|
||||
t.Fatalf("Delete(other): %v", err)
|
||||
}
|
||||
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
|
||||
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after delete: %v", err)
|
||||
}
|
||||
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
||||
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user