feat(backend): give every Bookmark an owner (Reader table) (#22)

A readers table appears, keyed by Discord user ID and carrying the SHA-256
of the owner's userscript token (the global API token today). Startup seeds
exactly one Reader from OWNER_DISCORD_ID, idempotently, and a run-once
migration (0004, version-table-gated) attaches existing bookmarks to it
before reshaping: the surrogate key column is dropped and bookmarks are
keyed (reader_id, site, series_id) with an FK to readers ON DELETE CASCADE,
so a duplicate bookmark for one Reader and Series is impossible at the
database level.

Every store read and write is now scoped to the reader it names; handlers
act as the seeded owner while the global token remains the only credential.
Authentication and the wire format are untouched: the flat JSON still
carries key/site/series_id, with key derived on read.

OWNER_DISCORD_ID is a new required env var (compose + docs updated).
This commit is contained in:
2026-08-08 07:59:40 +07:00
parent 984965ed9f
commit 3f7664ef9b
15 changed files with 553 additions and 164 deletions
+5 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
@@ -35,7 +36,9 @@ func newTestServer(t *testing.T) http.Handler {
func newTestStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.Open(pgtest.URL(t))
s, err := store.Open(pgtest.URL(t), store.Owner{
DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash")),
})
if err != nil {
t.Fatalf("store.Open: %v", err)
}
@@ -58,7 +61,7 @@ func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
if _, err := s.Upsert(store.Bookmark{
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: key,
Site: site,
SeriesID: seriesID,