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:
@@ -0,0 +1,17 @@
|
||||
-- One row per person. Keyed by their Discord user ID; carries the SHA-256 of
|
||||
-- their userscript token and when they were created. Hashed because a token
|
||||
-- in the database is a token anyone with the database can replay; SHA-256 is
|
||||
-- enough because the tokens are high-entropy random values with nothing to
|
||||
-- brute-force. No one can register yet, so this table holds exactly the one
|
||||
-- owner row the seed creates at startup (see Store.Open).
|
||||
CREATE TABLE readers (
|
||||
id bigserial PRIMARY KEY,
|
||||
discord_id text NOT NULL UNIQUE,
|
||||
token_sha256 bytea NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Every bookmark now belongs to a reader. Added nullable: rows created before
|
||||
-- this migration have no owner yet — 0004 attaches them to the seeded owner
|
||||
-- before NOT NULL and the composite key land.
|
||||
ALTER TABLE bookmarks ADD COLUMN reader_id bigint;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Attach every pre-existing bookmark to the owner reader, seeded between the
|
||||
-- two migrate passes (Store.Open). The oldest reader is the owner by
|
||||
-- construction: only the seed creates readers, and it runs once per database.
|
||||
-- Run-once via the version table, like every migration.
|
||||
UPDATE bookmarks SET reader_id = (SELECT id FROM readers ORDER BY id LIMIT 1);
|
||||
|
||||
-- Ownership lands structurally: reader_id becomes part of the key, so a
|
||||
-- bookmark is one Reader's progress on one Series and a duplicate for the
|
||||
-- same pair is impossible at the database level. Deleting a Reader takes
|
||||
-- their bookmarks with them. The old text key is gone — the wire "key" is
|
||||
-- derived as site:series_id on read, and nothing references the column.
|
||||
-- Dropping it drops the primary key it carried; the composite key replaces
|
||||
-- it, and the FK index the series constraint needs is created automatically.
|
||||
ALTER TABLE bookmarks
|
||||
ALTER COLUMN reader_id SET NOT NULL,
|
||||
DROP COLUMN key,
|
||||
ADD PRIMARY KEY (reader_id, site, series_id),
|
||||
ADD CONSTRAINT bookmarks_reader_fk
|
||||
FOREIGN KEY (reader_id) REFERENCES readers (id) ON DELETE CASCADE;
|
||||
Reference in New Issue
Block a user