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
+13 -5
View File
@@ -21,14 +21,21 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
its own database (`pgtest.URL(t)`). A package whose tests touch the store
must have that `TestMain`.
- **Single-user store, two tables.** `series` keyed `(site, series_id)`
- **Single-owner store, three tables.** `readers` is keyed by Discord user ID
and carries the SHA-256 of the owner's userscript token (the global
`API_TOKEN` today; issue #22). The seed creates exactly one row at startup.
`series` keyed `(site, series_id)`
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
Latest Chapter, `latest_checked_at` — and `bookmarks` holds only what
differs between readers: progress, favourite, lifecycle bucket,
`updated_at`. Sync **last-write-wins**; the wire format stays flat
(ADR-0004). `Store.Upsert` decomposes one flat body across both tables and
enforces the ownership rule: client `title`/`series_url`/`cover` are
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
surrogate id; the wire `key` is derived as `site:series_id` on read — and
every store read/write is scoped to the reader it names. `Store.OwnerID()`
is the seeded owner, which every handler passes while the global token is
still the only credential. Sync **last-write-wins**; the wire format stays
flat (ADR-0004). `Store.Upsert` decomposes one flat body across two tables
and enforces the ownership rule: client `title`/`series_url`/`cover` are
written only when the series row is new (ADR-0003).
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
- **Web UI:** same binary serve password-gated browser UI on second
@@ -91,7 +98,8 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`excluded.*` is post-evaluation row and default applied there would
wipe bucket on every PUT from client that predates column. See
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list),
- **Config via env:** `API_TOKEN`, `OWNER_DISCORD_ID` (seeds the owner Reader;
required), `ALLOWED_ORIGINS` (comma list),
`DATABASE_URL` (Postgres connection URL, required — no default),
`PORT` (default `8080`), `WEB_PASSWORD`
(gates browser UI; unset disable it),