From b0bf6fe770c0a4fd042a77769f146466fb4f363b Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 8 Aug 2026 20:01:42 +0700 Subject: [PATCH] feat: register any guild member as a Reader (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 25 +- AGENTS.md | 4 +- DEPLOY.md | 24 +- PRODUCT.md | 34 ++- README.md | 11 +- backend/AGENTS.md | 32 ++- backend/api_test.go | 12 +- backend/internal/httpmw/middleware.go | 30 +- backend/internal/store/sessions.go | 11 + backend/internal/store/store.go | 80 +++++- backend/internal/store/store_test.go | 167 ++++++++++- backend/internal/userscript/userscript.go | 24 +- backend/internal/web/discord.go | 29 +- backend/internal/web/static/style.css | 22 ++ backend/internal/web/templates/app.html | 2 + backend/internal/web/templates/chrome.html | 6 +- backend/internal/web/templates/list.html | 12 + backend/internal/web/templates/readers.html | 26 ++ backend/internal/web/web.go | 64 ++++- backend/main.go | 64 +---- backend/main_test.go | 2 +- backend/reader_credential_test.go | 145 ++++------ backend/web_test.go | 304 +++++++++++++++----- docker-compose.yml | 10 +- 24 files changed, 774 insertions(+), 366 deletions(-) create mode 100644 backend/internal/web/templates/readers.html diff --git a/.env.example b/.env.example index f8f9955..7f1e213 100644 --- a/.env.example +++ b/.env.example @@ -6,17 +6,10 @@ # openssl rand -hex 32 TOKEN_KEY=changeme-generate-a-long-random-token -# Retired global credential, kept only during the cutover window so -# already-installed scripts keep working. Remove both it and -# API_TOKEN_GRACE_UNTIL once the window has passed and every device has -# reinstalled through the web UI. -# API_TOKEN= -# Moment the retired credential stops resolving to the owner (YYYY-MM-DD or -# RFC3339). Enforced in code on every request; unset means it is already dead. -# API_TOKEN_GRACE_UNTIL=2026-08-22 - -# The owner's Discord user ID — the one Reader every bookmark belongs to -# (seeded at startup). Discord snowflake, e.g. 1046923170000000000. +# The owner's Discord user ID — seeded at startup as the first Reader, the +# administrator (the only one who can revoke another Reader's sessions), and +# the owner of every bookmark that predates registration. Discord snowflake, +# e.g. 1046923170000000000. OWNER_DISCORD_ID=changeme-your-discord-user-id # Comma-separated origins allowed to call the API (CORS). Both Asura domains @@ -42,10 +35,12 @@ POSTGRES_PASSWORD=changeme-generate-a-long-random-password # TRAEFIK_CERTRESOLVER=le # --- Web UI (Discord OAuth) --- -# Sign-in is a Discord authorization code grant (ADR-0002): the owner signs in -# with Discord, and guild membership gates access. Create the application at -# https://discord.com/developers/applications and register the exact callback -# URL ($BOOKMARK_WEB_HOST/auth/discord/callback) as an OAuth2 redirect. +# Sign-in is a Discord authorization code grant (ADR-0002), and it is also +# registration: any member of the configured guild becomes a Reader on their +# first successful login, with their own empty library. Create the application +# at https://discord.com/developers/applications and register the exact +# callback URL ($BOOKMARK_WEB_HOST/auth/discord/callback) as an OAuth2 +# redirect. DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= # The guild whose membership gates sign-in (Developer Mode -> right-click the diff --git a/AGENTS.md b/AGENTS.md index f94dd25..2447414 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,8 @@ Go backend: - `html/template` only for anything a browser parses, never `text/template`. Never wrap stored or fetched strings in `template.HTML`/`JS`/`URL`; that switches off the escaping every template depends on. - Any outbound fetch of a client-supplied URL passes `fetchableSeriesURL` (site + `https` + host check) first. `series_url` arrives in a PUT body, so without the gate the poller will probe arbitrary hosts from the server's own network position. New fetch path reuses the gate rather than re-deriving one. - Cap every remote body with `io.LimitReader` (`maxBodyBytes`). An unbounded read is an OOM handed to whatever is on the other end. -- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. Covers the retired global token during its grace window. -- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `API_TOKEN`, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header. +- Compare secrets with `hmac.Equal` / `subtle.ConstantTimeCompare`, never `==`. A credential is matched by the SHA-256 the `readers` table holds, which is already a fixed-width equality — a new secret comparison must not regress to `==`. +- Errors: generic text to the client (`http.Error(w, "internal error", 500)`), detail to `log.Printf`. Never log `TOKEN_KEY`, a Reader's credential, `DISCORD_CLIENT_SECRET`, a session id, or a whole `Authorization` header. - Proxy headers are trusted only where they already are: `X-Forwarded-Proto` for the Secure cookie flag, **rightmost** `X-Forwarded-For` for client IP (leftmost is attacker-supplied). Don't read either anywhere else. - Session cookies keep `HttpOnly`, `SameSite`, `Secure`-when-HTTPS; expiry is enforced by the `sessions` table lookup, not a signature. - Stdlib crypto only. No hand-rolled hashing, no MD5/SHA-1 anywhere security-bearing. diff --git a/DEPLOY.md b/DEPLOY.md index dd4b02c..f87b10b 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -36,8 +36,9 @@ Edit `.env`: # Only SHA-256 hashes of credentials are stored. TOKEN_KEY= -# Required — the owner's Discord user ID. Seeds the one Reader every bookmark -# belongs to; the value is the snowflake in your Discord profile (Settings → +# Required — the owner's Discord user ID. Seeds the first Reader: the +# administrator, and the owner of every bookmark that predates registration. +# The value is the snowflake in your Discord profile (Settings → # Advanced → Developer Mode → right-click your name → Copy User ID). OWNER_DISCORD_ID= @@ -74,10 +75,8 @@ grep -E '^TOKEN_KEY=' .env `TOKEN_KEY` derives every Reader's userscript credential (issue #24); only SHA-256 hashes of the credentials are stored, so this secret is what a -database leak alone cannot recover. If you are upgrading across the cutover, -also set `API_TOKEN` (the retired global credential) and -`API_TOKEN_GRACE_UNTIL` in `.env` so already-installed scripts keep working -for the window — see §6. +database leak alone cannot recover. Changing it invalidates every installed +script at once. `POSTGRES_PASSWORD` is read **only while the `postgres-data` volume is empty**, which in practice means at first boot. Changing it afterwards changes the URL @@ -124,8 +123,10 @@ gated by membership in one configured guild. The guild id is in Discord's client with Developer Mode on: right-click the server name → Copy Server ID. The four uncommented variables are required — - the backend refuses to start without them. `OWNER_DISCORD_ID` from §1 is the - only Discord identity allowed to sign in while registration is closed. + the backend refuses to start without them. Guild membership *is* + registration: any member of `DISCORD_GUILD_ID` becomes a Reader with their + own library on their first sign-in. `OWNER_DISCORD_ID` from §1 is only the + administrator — the Reader who can revoke another Reader's sessions. 4. Redeploy and check: @@ -188,9 +189,10 @@ curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok curl -s -o /dev/null -w '%{http_code}\n' \ https://bookmark-api.violetcrown.my.id/bookmarks # -> 401 -# During the grace window the retired global credential still resolves to the -# owner; afterwards it is 401 like any other wrong credential. -TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) +# A Reader's own credential. It is derived, never stored in .env — take it from +# the Userscripts panel's install link after signing in, or from an installed +# script's API_TOKEN constant. +TOKEN= curl -s -H "Authorization: Bearer $TOKEN" \ https://bookmark-api.violetcrown.my.id/bookmarks # -> [] diff --git a/PRODUCT.md b/PRODUCT.md index a4ec0b0..a6ec893 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,47 +8,53 @@ web ## Users -Single user (self-hosted, no accounts, no multi-user planned). Reads manga on **asurascans.com** and **demonicscans.org** primarily via Bromite on mobile, also checks/updates from a desktop browser. The web UI is the cross-device view into progress captured by the userscript while reading. +Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (revoking another Reader's sessions). + +Reading happens on **asurascans.com**, **demonicscans.org**, **comix.to** and **kagane.to** for manga and **novelfull.com** and **lightnovelworld.net** for novels, primarily via Bromite on mobile, with checks and corrections from a desktop browser. The web UI is the cross-device view into progress the userscripts capture while reading. ## Product Purpose -Tracks read-progress ("last chapter read") per manga series across two otherwise-unrelated manga sites that each have their own separate `localStorage`. A Go backend unifies bookmarks into one store; the web UI is a password-gated browser view of that store for reviewing, favouriting, correcting, or removing bookmarks, and jumping back into a series to continue reading. A background poller also refreshes each series' latest-published-chapter so the list can flag "NEW" without the user visiting the site. +Tracks read-progress ("last chapter read") per series across sites that each have their own separate `localStorage`. A Go backend unifies bookmarks into one store; the web UI is a Discord-gated browser view of one Reader's own bookmarks, for reviewing, favouriting, correcting, shelving or removing them, and jumping back into a series to continue reading. A background poller refreshes each series' latest-published-chapter so the list can flag "NEW" without the Reader visiting the site. ## Positioning -Not a public reading tracker or social app — a private, self-hosted sync layer purpose-built for two specific scraped sites, with no server-side account system (single bearer token + one password-gated session). +Not a public reading tracker or social app — a private, self-hosted sync layer for one Discord community, purpose-built for a fixed set of scraped sites. Multi-Reader, not multi-tenant: libraries are isolated, but the deployment belongs to one group and its membership is the whole access model. ## Operating Context -- Primary reading device: Bromite (mobile Chromium), where a userscript captures progress automatically. +- Primary reading device: Bromite (mobile Chromium), where a userscript captures progress automatically. Each Reader installs their own copy, rendered with their own credential. - Web UI is a secondary surface: checking list state, correcting a wrong chapter number, removing dead bookmarks, jumping to "continue reading." -- Manga cover art and titles come from the source sites' `og:image`/`og:title` — real content, not placeholders. -- List order is driven by `updated_at`, which moves only on real reading progress (not favouriting, not a newly detected chapter) — a UI constraint the redesign must not break. +- Cover art and titles come from the source sites' `og:image`/`og:title` — real content, not placeholders. They are facts about the series, so they are shared between Readers who track it; progress is not. +- List order is driven by `updated_at`, which moves only on real reading progress (not favouriting, not a newly detected chapter) — a UI constraint the design must not break. ## Capabilities and Constraints -- Two tabs: All / Favourites. Search-filter by title (client-side, `filter.js`). -- Card actions: continue (opens source site), toggle favourite, manual chapter override, delete (with confirm). -- "Continue reading" horizontal strip for recently-progressed series. -- htmx-driven partial updates (card re-render on favourite/chapter/delete), no client-side framework/build step — templates are Go `html/template`, `go:embed`-ed. +- Two libraries (manga, novels) with lifecycle tabs: All / Updated / Favourites / Archived / Finished. Search-filter by title (client-side, `filter.js`). +- Card actions: continue (opens source site), toggle favourite, manual chapter override, archive, finish, remove — each move out of the list confirm-gated. +- "Continue reading" horizontal strip for series with an unread chapter. +- A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page. +- Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side. +- The owner can revoke a specific Reader's sessions; nothing else in the UI differs by Reader. +- htmx-driven partial updates, no client-side framework or build step — templates are Go `html/template`, `go:embed`-ed. - Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint. ## Brand Commitments - Name: **BookmarkManager**. -- **Dark-first is binding**: current dark-by-default / light-follows-system-preference behavior must be preserved as a design constraint, not just a starting default, because reading happens at night. +- **Dark-first is binding**: dark-by-default / light-follows-system-preference must be preserved as a design constraint, not just a starting default, because reading happens at night. ## Evidence on Hand -- Live templates/CSS at `backend/templates/*.html`, `backend/static/style.css` — current implemented UI, functional but not yet treated as an intentional design system. -- No logo, screenshots, or marketing copy exist; none should be fabricated. +- Live templates/CSS at `backend/internal/web/templates/*.html`, `backend/internal/web/static/style.css`, governed by the Cinder design system (`docs/design-system.md`). +- No logo beyond the wordmark, no screenshots, no marketing copy; none should be fabricated. ## Product Principles - Dark-first, night-reading-optimized — never regress to a light-default or high-glare surface. - Mobile is the primary target; desktop is an enhancement, not the design center. - Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over. -- No accounts, no multi-tenant chrome — the whole product is for one reader. +- A leak between Readers fails silently and looks like working software — isolation is asserted from both directions, never inferred from counting one Reader's rows. +- No roles, no admin console, no org chrome: one owner capability and otherwise every Reader's view is the same. - Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo. ## Accessibility & Inclusion diff --git a/README.md b/README.md index 5ef8763..35a8250 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,14 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) | Var | Default | Notes | |-----|---------|-------| | `TOKEN_KEY` | *(required)* | Secret every Reader's userscript credential is derived from (issue #24); only SHA-256 hashes of credentials are stored. | -| `API_TOKEN` | *(retired)* | Global credential, honoured only until `API_TOKEN_GRACE_UNTIL` for already-installed scripts; remove both after the window. | -| `API_TOKEN_GRACE_UNTIL` | unset | Moment the retired credential stops resolving to the owner (`YYYY-MM-DD` or RFC3339), enforced in code. | -| `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner; seeds the one Reader all bookmarks belong to. | +| `OWNER_DISCORD_ID` | *(required)* | Discord user ID of the owner: seeded as the first Reader, owns every pre-registration bookmark, and is the only Reader who can revoke another's sessions. | | `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. | | `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. | | `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. | | `BROWSER_WS_URL` | `ws://172.28.0.10:9222` | Headless-shell CDP endpoint used to poll Kagane past its JS challenge. Must be an IP or `localhost` — Chrome's DevTools handler 500s any other Host header. | | `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). | | `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. | -| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. | +| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. | | `DISCORD_REDIRECT_URI` | *(required)* | Exact callback URL; Discord matches it verbatim against the registered redirect. | | `DISCORD_REQUIRED_ROLE` | empty | Role snowflake a member must additionally hold. Empty means guild membership alone suffices. | | `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. | @@ -101,7 +99,10 @@ docker compose up -d --build # binds 127.0.0.1:8080 Smoke test: ```bash -TOKEN=$(grep '^API_TOKEN=' .env | cut -d= -f2) +# The credential is per Reader and derived, so there is no token in .env to +# grep. Take yours from the Userscripts panel's install link after signing in, +# or read it out of an installed script's API_TOKEN constant. +TOKEN= curl -s localhost:8080/healthz # ok curl -s localhost:8080/bookmarks # 401 curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/bookmarks # [] diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7e24ffa..736710a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -21,9 +21,9 @@ 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-owner store, four tables.** `readers` is keyed by Discord user ID +- **Reader-owned store, four tables.** `readers` is keyed by Discord user ID and carries the SHA-256 of the Reader's userscript credential plus a - `token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates exactly one row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI. + `token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the **owner** row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (`Store.EnsureReader`, idempotent on `discord_id`, and it never rewrites an existing row's hash). Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI. `series` keyed `(site, series_id)` (`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`), @@ -32,10 +32,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `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. Auth resolves the - acting Reader from the presented credential (`httpmw.Auth`), and the - reader id travels in the request context; the retired global `API_TOKEN` - additionally resolves to the owner until `API_TOKEN_GRACE_UNTIL`, with - every such acceptance logged. Sync **last-write-wins**; the wire format + acting Reader from the presented credential (`httpmw.Auth`) and nothing + else — there is no unauthenticated-by-Reader route and no global token; the + reader id travels in the request context. 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). @@ -49,8 +48,15 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `internal/` tree, not just `*.go`. Sessions are rows in the `sessions` table: the cookie carries only an opaque id, looked up (and expiry- checked) on every request, and deleting the row revokes the session. - The owner's Discord ID is the only identity that can sign in while - registration is closed. UI mutations read-modify-write + Guild membership *is* registration (issue #27): `discordCallback` gates on + membership (and `DISCORD_REQUIRED_ROLE` when set) and then calls + `Store.EnsureReader`, so a refusal creates nothing and a returning Reader + reuses their row. The owner is the only Reader with administrative reach: + `POST /readers/{id}/revoke` (404 for anyone else) drops that Reader's + sessions, and the `readers` panel renders only on the owner's page. + A Reader with no bookmarks at all sees `listView.Fresh`, whose empty state + offers both install links instead of describing a filter. + UI mutations read-modify-write through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. **Design-tool caveat:** templates link `/static/style.css` root-absolutely @@ -104,10 +110,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN wipe bucket on every PUT from client that predates column. See `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. - **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential; - required), `API_TOKEN` + `API_TOKEN_GRACE_UNTIL` (retired global credential - and the moment it stops resolving to the owner — both removed after the - cutover window, enforced in code), `OWNER_DISCORD_ID` (seeds the owner Reader; - required), `ALLOWED_ORIGINS` (comma list), + required), `OWNER_DISCORD_ID` (seeds the owner Reader — the administrator and + the owner of every pre-registration bookmark; required), + `ALLOWED_ORIGINS` (comma list), `DATABASE_URL` (Postgres connection URL, required — no default), `PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/ `_REDIRECT_URI` (required; Discord OAuth for the browser UI), @@ -132,3 +137,6 @@ 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. diff --git a/backend/api_test.go b/backend/api_test.go index a4a80c4..46efeb2 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -17,8 +17,6 @@ import ( "bookmarkmanager/backend/internal/token" ) -const testToken = "s3cret-token" - // testTokenKey derives every test Reader's credential; it must match the key // newTestStoreURL seeds the owner with, or derived credentials authenticate // nothing. @@ -30,9 +28,7 @@ const testDiscordID = "test-owner" func testConfig() Config { return Config{ - Token: testToken, TokenKey: testTokenKey, - GraceUntil: time.Now().Add(24 * time.Hour), AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, Port: "8080", } @@ -72,8 +68,10 @@ func newTestStoreURL(t *testing.T) (*store.Store, string) { return s, url } +// auth authenticates a request as the owner Reader, whose derived credential +// is the only thing the API accepts. func auth(req *http.Request) *http.Request { - req.Header.Set("Authorization", "Bearer "+testToken) + req.Header.Set("Authorization", "Bearer "+ownerCredential()) return req } @@ -134,7 +132,7 @@ func TestAuthRequired(t *testing.T) { }{ {"no header", ""}, {"bad token", "Bearer wrong"}, - {"not bearer", "Basic " + testToken}, + {"not bearer", "Basic " + ownerCredential()}, {"empty bearer", "Bearer "}, } for _, tc := range cases { @@ -604,7 +602,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { "series_url":"https://asurascans.com/comics/x", "last_chapter":"Chapter 5","last_chapter_num":5}` req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+testToken) + req.Header.Set("Authorization", "Bearer "+ownerCredential()) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) diff --git a/backend/internal/httpmw/middleware.go b/backend/internal/httpmw/middleware.go index a233969..d217789 100644 --- a/backend/internal/httpmw/middleware.go +++ b/backend/internal/httpmw/middleware.go @@ -3,11 +3,9 @@ package httpmw import ( "compress/gzip" "context" - "crypto/subtle" "log" "net/http" "strings" - "time" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/token" @@ -26,39 +24,29 @@ func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(i // ResolveReader maps a presented credential to a Reader. The credential is // hashed and matched against readers.token_sha256 — an equality on 32-byte -// values, never a comparison of the credential itself — and, during the -// cutover window, the retired global token resolves to the owner. Every -// legacy acceptance is logged so the window can be confirmed empty before -// the token is removed. The same resolution backs the API bearer header and -// the userscript download path, so the window covers both. -func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) { - if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil { +// values, never a comparison of the credential itself. The same resolution +// backs the API bearer header and the userscript download path, so a Reader +// has exactly one credential with one blast radius. +func ResolveReader(s *store.Store, cred string) (int64, bool) { + readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)) + if err != nil { log.Printf("auth: reader lookup: %v", err) return 0, false - } else if ok { - return readerID, true } - - if legacy != "" && time.Now().Before(graceUntil) && - subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 { - log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)", - s.OwnerID(), graceUntil.Format(time.RFC3339)) - return s.OwnerID(), true - } - return 0, false + return readerID, ok } // Auth guards a handler with a per-Reader bearer credential. The acting // Reader travels in the request context, so a handler scopes every store call // to exactly the Reader that authenticated. -func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler { +func Auth(s *store.Store, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, bearerPrefix) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix)) + readerID, ok := ResolveReader(s, strings.TrimPrefix(h, bearerPrefix)) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return diff --git a/backend/internal/store/sessions.go b/backend/internal/store/sessions.go index f4578b7..9c29dc4 100644 --- a/backend/internal/store/sessions.go +++ b/backend/internal/store/sessions.go @@ -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 +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index de2a911..6d6decc 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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. diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index c5bfaa6..1105e63 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -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) + } +} diff --git a/backend/internal/userscript/userscript.go b/backend/internal/userscript/userscript.go index 6f189ba..5e7ae5c 100644 --- a/backend/internal/userscript/userscript.go +++ b/backend/internal/userscript/userscript.go @@ -10,7 +10,6 @@ import ( "bookmarkmanager/backend/internal/httpmw" "bookmarkmanager/backend/internal/store" - "bookmarkmanager/backend/internal/token" ) // tokenPlaceholder is what the bindmounted userscript carries where the @@ -87,26 +86,15 @@ func Render(w http.ResponseWriter, r *http.Request, path, credential string) { // the route exists. The same credential authenticates the API bearer header, // so the two are one secret with one blast radius. // -// The credential substituted is the resolved Reader's derived one, not the -// raw path segment: while the retired global token is still accepted during -// the grace window (httpmw.ResolveReader), an already-installed script -// polling its legacy URL is served a copy carrying the Reader's own -// credential, so the next update poll migrates the device onto its per-Reader -// path — the window empties itself instead of ending in a silent 401 for -// every device that never visited the web UI. -func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc { +// The path segment is the credential itself, so once it resolves it is also +// exactly what the served copy must carry — no re-derivation needed. +func Handler(s *store.Store, path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token")) - if !ok { + cred := r.PathValue("token") + if _, ok := httpmw.ResolveReader(s, cred); !ok { http.NotFound(w, r) return } - discordID, epoch, err := s.ReaderTokenInfo(readerID) - if err != nil { - log.Printf("userscript: reader %d token info: %v", readerID, err) - http.NotFound(w, r) - return - } - Render(w, r, path, token.Token(tokenKey, discordID, epoch)) + Render(w, r, path, cred) } } diff --git a/backend/internal/web/discord.go b/backend/internal/web/discord.go index f25ab4c..6949418 100644 --- a/backend/internal/web/discord.go +++ b/backend/internal/web/discord.go @@ -16,6 +16,7 @@ import ( "time" "bookmarkmanager/backend/internal/session" + "bookmarkmanager/backend/internal/token" ) const ( @@ -50,9 +51,6 @@ type DiscordConfig struct { // RedirectURI is the full public URL of the callback — Discord requires // the exact string, so it is configured, never derived from headers. RedirectURI string - // OwnerDiscordID is the only Discord identity allowed to sign in until - // registration exists (issue #23). - OwnerDiscordID string } // oauthStates stores one-time sign-in states. A state is generated at @@ -166,14 +164,6 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) { "Discord sign-in is unavailable right now. Try again in a moment.") return } - - if userID != h.discord.OwnerDiscordID { - h.limiter.Fail(ip, time.Now()) - h.renderLogin(w, http.StatusForbidden, - "This Discord account is not the library owner.") - return - } - member, isMember, err := h.discordMember(r.Context(), tok.AccessToken) if err != nil { h.limiter.Fail(ip, time.Now()) @@ -185,6 +175,10 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) { // The refusal is the same for a non-member and a member without the // required role, and it names neither the guild nor its id: an outsider // cannot tell whether the guild exists, let alone which one gates. + // + // It also returns before EnsureReader, so a refused sign-in leaves no + // Reader row behind — the gate is the only thing standing between guild + // membership and a library. if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) { h.limiter.Fail(ip, time.Now()) h.renderLogin(w, http.StatusForbidden, @@ -192,8 +186,19 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) { return } + // Registration is the login (issue #27): first sight of a guild member + // creates their Reader, every later sight returns the same one. Their + // userscript credential is derived at epoch 0 the way the owner's is, so + // the install links work before they have read anything. + readerID, err := h.store.EnsureReader(userID, token.Hash(token.Token(h.tokenKey, userID, 0))) + if err != nil { + log.Printf("register reader: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.limiter.Reset(ip) - sess, err := h.store.CreateSession(session.NewID(), h.readerID, session.SessionTTL) + sess, err := h.store.CreateSession(session.NewID(), readerID, session.SessionTTL) if err != nil { log.Printf("create session: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 2060619..d8bd889 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -290,6 +290,28 @@ 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 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 16px; + min-height: 44px; + border-top: 1px solid var(--rule); +} +.readerlist form { margin: 0 0 0 auto; } +.reader-id { font: 400 13px/1.4 var(--font-mono); color: var(--paper); } +.reader-sessions { + font: 400 11px/1.4 var(--font-mono); + letter-spacing: .04em; + color: var(--mute); +} +/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for + the new-chapter signal. */ +.ghost.danger { color: var(--danger); } +.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); } + .chrome { display: flex; flex-direction: column; } .searchbar { diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html index 349cc10..ae6586a 100644 --- a/backend/internal/web/templates/app.html +++ b/backend/internal/web/templates/app.html @@ -76,6 +76,8 @@ {{template "setup" .}} + {{if .Owner}}{{template "readers" .}}{{end}} + {{template "keyrow" .}} {{template "recent" .}} diff --git a/backend/internal/web/templates/chrome.html b/backend/internal/web/templates/chrome.html index 7537c9a..2d7c469 100644 --- a/backend/internal/web/templates/chrome.html +++ b/backend/internal/web/templates/chrome.html @@ -30,9 +30,11 @@ {{/* The action key. The icon strip on a card is unlabelled, so one permanent line under the tabs names every glyph. It follows the tab rather than the row: the archived and finished buckets swap Archive for Restore, and a - finished series has no Done to offer. */}} + finished series has no Done to offer. A Reader with no cards at all has + nothing for it to name, so it hides rather than disappearing — see the + note above about out-of-band swaps needing their target to exist. */}} {{define "keyrow"}} -
+
Read Fav Chapter diff --git a/backend/internal/web/templates/list.html b/backend/internal/web/templates/list.html index a0efda3..8102309 100644 --- a/backend/internal/web/templates/list.html +++ b/backend/internal/web/templates/list.html @@ -8,6 +8,18 @@ No titles match “”.
+{{else if .Fresh}} + {{/* Nothing anywhere, not an empty bucket: this Reader has just registered, + so the empty state is the setup instruction rather than a filter + report. Both scripts, because the two libraries are separate installs. */}} +
+ Your library is empty. +

Install both userscripts, then open a series and read a chapter — bookmarks arrive on their own.

+ +
{{else if eq .Tab "fav"}}
No favourites yet.

Star a series to pin it here.

{{else if eq .Tab "new"}} diff --git a/backend/internal/web/templates/readers.html b/backend/internal/web/templates/readers.html new file mode 100644 index 0000000..4f192e0 --- /dev/null +++ b/backend/internal/web/templates/readers.html @@ -0,0 +1,26 @@ +{{/* 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. */}} +{{define "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.

+
    + {{range .Readers}} +
  • + {{.DiscordID}} + {{.Sessions}} session{{if ne .Sessions 1}}s{{end}} + {{if .Sessions}} +
    + +
    + {{end}} +
  • + {{end}} +
+
+{{end}} diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index f9c0f39..5405ec5 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -34,10 +34,6 @@ const RecentCount = 5 // representations (HTML versus JSON) to different clients under different auth. type Handler struct { store *store.Store - // readerID is the owner Reader's id, the only Reader that can exist - // while registration is closed (issue #23). Every session row points at - // it, so it is also the Reader the UI acts as. - readerID int64 // tokenKey derives Readers' userscript credentials (internal/token): the // install endpoints render the scripts with the credential inside, which // is the one place the UI needs the secret. @@ -76,6 +72,16 @@ type listView struct { // Rotated marks the setup panel as having just rotated the credential: // it swaps the reinstall warning in over the button row. Rotated bool + // Fresh means this Reader has no bookmarks at all, in either library — a + // brand-new registration rather than an empty bucket. The empty state then + // explains how a library gets filled instead of describing a filter. + Fresh bool + // Owner marks the acting Reader as the deployment's owner, which unlocks + // the Readers panel. 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. + Readers []store.ReaderSummary } // PageURL and ListURL are the two link shapes every tab needs. Building them @@ -102,14 +108,13 @@ 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, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) { +func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err } return &Handler{ store: s, - readerID: readerID, tokenKey: tokenKey, mangaUserscriptPath: mangaPath, novelUserscriptPath: novelPath, @@ -141,6 +146,9 @@ 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)) } // staticHandler serves the embedded assets. An hour, not longer: assets are @@ -229,6 +237,14 @@ 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 = true + if view.Readers, err = h.store.Readers(); err != nil { + log.Printf("index readers: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } h.render(w, http.StatusOK, "app", view) } @@ -276,6 +292,10 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro if err != nil { return listView{}, err } + // Fresh is about the Reader, not the library, so it is taken before the + // filter narrows the slice: it decides whether an empty list reads as + // "install the scripts" or "this bucket is empty". + fresh := len(all) == 0 // Narrow to one library first: reading, withNew and recent all derive from // this slice, so doing it later would let the other library's rows into the // strip and the Updated badge. @@ -319,7 +339,8 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro recent = recent[:RecentCount] } } - return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil + return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, + NewCount: len(withNew), Fresh: fresh}, nil } func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) { @@ -594,3 +615,32 @@ 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 + } + 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}) +} diff --git a/backend/main.go b/backend/main.go index 1fb7e1d..f44d208 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,26 +23,18 @@ import ( // Config holds all runtime settings, sourced from environment variables. type Config struct { - // Token is the retired global API token, kept only for the cutover grace - // window: while GraceUntil has not passed, it resolves to the owner - // Reader so already-installed scripts keep working. Unset after the - // window closes. - Token string // TokenKey derives every Reader's userscript credential (internal/token). // Required: without it no install URL can ever be built. - TokenKey string - // GraceUntil is the moment the retired global token stops resolving to - // the owner Reader. Zero means the token is already dead. Enforced in - // code on every request, not by a runbook note. - GraceUntil time.Time + TokenKey string AllowedOrigins []string // DatabaseURL is the Postgres connection URL; required, no default, // because a wrong guess would silently start on an empty database. DatabaseURL string Port string // OwnerDiscordID identifies the seeded owner Reader (issue #22). Required: - // bookmarks are scoped to a Reader, and without an owner there is none. - // It is also the only Discord identity allowed to sign in (issue #23). + // bookmarks are scoped to a Reader, and a fresh deployment needs one + // before anybody logs in. The owner is also the only Reader who can revoke + // another Reader's sessions. OwnerDiscordID string // Discord is the OAuth application the browser UI signs in with. Discord web.DiscordConfig @@ -158,29 +150,9 @@ func loadLatestPoll() LatestPoll { return p } -// parseGraceUntil reads the retired-token deadline. Both a bare date and a -// full RFC3339 timestamp are accepted; an unparseable value is a -// configuration bug, not a gracefully-degraded feature — the whole point is -// that the window's end is enforced, so fail loud. -func parseGraceUntil(raw string) time.Time { - raw = strings.TrimSpace(raw) - if raw == "" { - return time.Time{} - } - for _, layout := range []string{time.RFC3339, "2006-01-02"} { - if t, err := time.Parse(layout, raw); err == nil { - return t - } - } - log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw) - return time.Time{} -} - func loadConfig() Config { c := Config{ - Token: os.Getenv("API_TOKEN"), TokenKey: os.Getenv("TOKEN_KEY"), - GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")), DatabaseURL: os.Getenv("DATABASE_URL"), Port: envOr("PORT", "8080"), OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"), @@ -189,13 +161,12 @@ func loadConfig() Config { LatestPoll: loadLatestPoll(), } c.Discord = web.DiscordConfig{ - ClientID: os.Getenv("DISCORD_CLIENT_ID"), - ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"), - GuildID: os.Getenv("DISCORD_GUILD_ID"), - RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"), - APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"), - RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"), - OwnerDiscordID: c.OwnerDiscordID, + ClientID: os.Getenv("DISCORD_CLIENT_ID"), + ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"), + GuildID: os.Getenv("DISCORD_GUILD_ID"), + RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"), + APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"), + RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"), } for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { if o = strings.TrimSpace(o); o != "" { @@ -218,9 +189,9 @@ func newRouter(s *store.Store, cfg Config) http.Handler { // instead, and the script is rendered with the resolved Reader's // credential substituted in. mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", - userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath)) + userscript.Handler(s, cfg.UserscriptPath)) mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", - userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath)) + userscript.Handler(s, cfg.NovelUserscriptPath)) h := &api.Handler{Store: s} protected := http.NewServeMux() @@ -228,13 +199,13 @@ func newRouter(s *store.Store, cfg Config) http.Handler { protected.HandleFunc("PUT /bookmarks/{key}", h.Put) protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete) - auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected) + auth := httpmw.Auth(s, protected) mux.Handle("/bookmarks", auth) mux.Handle("/bookmarks/", auth) // 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, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey), + wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey), cfg.UserscriptPath, cfg.NovelUserscriptPath) if err != nil { log.Fatalf("web handler: %v", err) @@ -282,13 +253,6 @@ func main() { log.Fatalf("%s is required", key) } } - if cfg.Token == "" && !cfg.GraceUntil.IsZero() { - log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not") - } - if cfg.Token != "" && cfg.GraceUntil.IsZero() { - log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival") - } - // The owner's userscript credential is derived from TOKEN_KEY at epoch 0 // (internal/token); the readers row carries its SHA-256, not the // credential itself. diff --git a/backend/main_test.go b/backend/main_test.go index cbc6d6a..431f391 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -202,7 +202,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { } func TestGzipCompressesTextNotFonts(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) + srv, _ := newWebTestServer(t, testConfig()) cases := []struct { path string diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go index e248de5..f2fa40f 100644 --- a/backend/reader_credential_test.go +++ b/backend/reader_credential_test.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "encoding/json" "io" "net/http" @@ -10,31 +9,19 @@ import ( "path/filepath" "strings" "testing" - "time" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/token" - - _ "github.com/jackc/pgx/v5/stdlib" ) -// insertReader creates an extra reader row (registration is closed, so the -// store has no path for this — tests reach past it) and returns its id. The -// credential is derived the same way the owner's is, so it authenticates -// through the real router. -func insertReader(t *testing.T, dbURL, discordID string) int64 { +// registerReader creates an extra Reader the way a first login does and +// returns its id. The credential is derived the same way the owner's is, so it +// authenticates through the real router. +func registerReader(t *testing.T, s *store.Store, discordID string) int64 { t.Helper() - db, err := sql.Open("pgx", dbURL) + id, err := s.EnsureReader(discordID, token.Hash(readerCredential(discordID))) if err != nil { - t.Fatalf("open db: %v", err) - } - defer db.Close() - hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0)) - var id int64 - if err := db.QueryRow( - `INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`, - discordID, hash[:]).Scan(&id); err != nil { - t.Fatalf("insert reader: %v", err) + t.Fatalf("register reader %q: %v", discordID, err) } return id } @@ -59,34 +46,29 @@ func withBody(req *http.Request, body string) *http.Request { return req } -// The retired global token resolves to the owner Reader only while the grace -// deadline is in the future — testConfig sets it, so the acceptance path is -// the existing auth() tests; this pins the other side of the window. -func TestLegacyTokenDeadAfterGrace(t *testing.T) { - s := newTestStore(t) - cfg := testConfig() - cfg.GraceUntil = time.Now().Add(-time.Hour) - srv := newRouter(s, cfg) +// 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()) rr := httptest.NewRecorder() - srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken)) + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered"))) if rr.Code != http.StatusUnauthorized { - t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code) + t.Fatalf("unregistered Reader's credential: status = %d, want 401", rr.Code) } - // The owner's own derived credential is unaffected by the window closing. rr = httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) if rr.Code != http.StatusOK { - t.Fatalf("derived token after grace: status = %d, want 200", rr.Code) + t.Fatalf("owner's derived credential: status = %d, want 200", rr.Code) } } // A Reader's credential authenticates exactly that Reader: rows written under // one credential are invisible to the other, on the same key. func TestPerReaderIsolation(t *testing.T) { - s, dbURL := newTestStoreURL(t) - insertReader(t, dbURL, "other-reader") + s := newTestStore(t) + registerReader(t, s, "other-reader") srv := newRouter(s, testConfig()) ownerKey := "asura:solo" @@ -141,20 +123,50 @@ func TestPerReaderIsolation(t *testing.T) { t.Fatalf("owner list = %+v, want their own row", owners) } - // One Reader's credential cannot delete the other's row. - req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader")) - rr = httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != http.StatusNoContent { - t.Fatalf("other reader delete: status = %d, want 204", rr.Code) + // DELETE is scoped to its caller too, asserted in both directions: each + // Reader's delete on the shared key takes only their own row. + list := func(cred string) []store.Bookmark { + t.Helper() + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", cred)) + var got []store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + return got } - rr = httptest.NewRecorder() - srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential())) - if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil { - t.Fatalf("decode: %v", err) + del := func(cred string) { + t.Helper() + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, cred)) + if rr.Code != http.StatusNoContent { + t.Fatalf("delete: status = %d, want 204", rr.Code) + } } - if len(owners) != 1 { - t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners) + + del(readerCredential("other-reader")) + if got := list(ownerCredential()); len(got) != 1 { + t.Fatalf("owner's row was deletable by the other Reader: %+v", got) + } + if got := list(readerCredential("other-reader")); len(got) != 0 { + t.Fatalf("other Reader's own delete left %+v behind", got) + } + + // The mirror: the other Reader takes the key again, the owner deletes + // theirs, and the other's row is untouched. + req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader")) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, withBody(req, body)) + if rr.Code != http.StatusOK { + t.Fatalf("other reader re-put: status = %d, want 200", rr.Code) + } + del(ownerCredential()) + if got := list(readerCredential("other-reader")); len(got) != 1 { + t.Fatalf("other Reader's row was deletable by the owner: %+v", got) + } + if got := list(ownerCredential()); len(got) != 0 { + t.Fatalf("owner's own delete left %+v behind", got) } } @@ -162,7 +174,7 @@ func TestPerReaderIsolation(t *testing.T) { // with the Reader's credential inside: the credential never appears in the // address bar, the page markup, or any Location header. func TestInstallServesScriptWithCredential(t *testing.T) { - cfg := webConfig() + cfg := testConfig() dir := t.TempDir() path := filepath.Join(dir, "manga-bookmark.user.js") novelPath := filepath.Join(dir, "novel-bookmark.user.js") @@ -225,43 +237,6 @@ func TestInstallServesScriptWithCredential(t *testing.T) { } } -// The retired global token also keeps the script download path working during -// the grace window — that is how already-installed scripts auto-update across -// the cutover — and dies with it. The copy served on the legacy path embeds -// the Reader's derived credential, so the next update poll migrates the -// device onto its per-Reader path: the window empties itself. -func TestLegacyTokenUserscriptPathDuringGrace(t *testing.T) { - path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") - if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { - t.Fatalf("write script: %v", err) - } - - s := newTestStore(t) - cfg := testConfig() - cfg.UserscriptPath = path - - // Within the window the legacy URL serves the script, but with the - // owner's derived credential substituted — not the legacy one. - rr := httptest.NewRecorder() - newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, - "/u/"+testToken+"/manga-bookmark.user.js", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code) - } - if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) { - t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got) - } - - // After the deadline the same URL is a 404 like any unknown credential. - cfg.GraceUntil = time.Now().Add(-time.Hour) - rr = httptest.NewRecorder() - newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, - "/u/"+testToken+"/manga-bookmark.user.js", nil)) - if rr.Code != http.StatusNotFound { - t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code) - } -} - // Rotation through the web UI invalidates the old credential immediately, // mints one that authenticates the API and the script path, and warns that // every device must reinstall. @@ -271,7 +246,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) { if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } - cfg := webConfig() + cfg := testConfig() cfg.UserscriptPath = path srv := newRouter(s, cfg) @@ -338,7 +313,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) { // The app page offers the install links; the credential never appears in its // markup. func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) { - srv, st := newWebTestServer(t, webConfig()) + srv, st := newWebTestServer(t, testConfig()) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() diff --git a/backend/web_test.go b/backend/web_test.go index 336208b..daf60ad 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1,13 +1,14 @@ package main import ( - "database/sql" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "reflect" "strconv" "strings" @@ -17,20 +18,13 @@ import ( "bookmarkmanager/backend/internal/session" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/web" - - _ "github.com/jackc/pgx/v5/stdlib" ) +// testOwnerID is the Discord identity the stub reports for a sign-in. It is +// deliberately not the seeded owner's (testDiscordID): registration is open, +// so the default sign-in is a second Reader registering. const testOwnerID = "owner-snowflake" -// webConfig returns a config whose web UI is usable: Discord identity is set, -// though the OAuth endpoints still need the stub URL from discordConfig. -func webConfig() Config { - cfg := testConfig() - cfg.Discord.OwnerDiscordID = testOwnerID - return cfg -} - // 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) { @@ -137,12 +131,11 @@ func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) { // the API base pointed at a stub. func discordConfig(stubURL string) web.DiscordConfig { return web.DiscordConfig{ - ClientID: "client-1", - ClientSecret: "client-secret-1", - GuildID: "guild-1", - APIBase: stubURL, - RedirectURI: "https://bm.example.com/auth/discord/callback", - OwnerDiscordID: testOwnerID, + ClientID: "client-1", + ClientSecret: "client-secret-1", + GuildID: "guild-1", + APIBase: stubURL, + RedirectURI: "https://bm.example.com/auth/discord/callback", } } @@ -151,7 +144,7 @@ func discordConfig(stubURL string) web.DiscordConfig { func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) { t.Helper() stub, srv := newDiscordStub(t) - cfg := webConfig() + cfg := testConfig() cfg.Discord = discordConfig(srv.URL) router, st := newWebTestServer(t, cfg) return router, st, stub @@ -190,24 +183,40 @@ func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.Resp return rr } -// readerCount pokes the readers table directly — the refusal contract is that -// nothing was created, which the store API would not show. -func readerCount(t *testing.T, url string) int { +// storeReaders is the roster, ordered oldest first — the owner heads it. +func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary { t.Helper() - db, err := sql.Open("pgx", url) + readers, err := st.Readers() if err != nil { - t.Fatalf("open: %v", err) + t.Fatalf("Readers: %v", err) } - defer db.Close() - var n int - if err := db.QueryRow(`SELECT count(*) FROM readers`).Scan(&n); err != nil { - t.Fatalf("count readers: %v", err) + return readers +} + +// signInCookie runs a whole Discord sign-in and returns the session cookie it +// minted, for the Reader the stub reports (testOwnerID). +func signInCookie(t *testing.T, srv http.Handler) *http.Cookie { + t.Helper() + rr := completeSignIn(t, srv, startSignIn(t, srv)) + cookies := rr.Result().Cookies() + if rr.Code != http.StatusSeeOther || len(cookies) != 1 { + t.Fatalf("sign-in status = %d with %d cookies, want 303 and one", rr.Code, len(cookies)) } - return n + return cookies[0] +} + +// signedInReader is signInCookie plus the Reader the session names. +func signedInReader(t *testing.T, srv http.Handler, st *store.Store) int64 { + t.Helper() + sess, ok, err := st.GetSession(signInCookie(t, srv).Value, time.Now()) + if err != nil || !ok { + t.Fatalf("session lookup: ok=%v err=%v", ok, err) + } + return sess.ReaderID } func TestIndexWithoutSessionShowsLogin(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) + srv, _ := newWebTestServer(t, testConfig()) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) @@ -220,7 +229,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) { } func TestIndexWithSessionShowsList(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) if _, err := st.Upsert(st.OwnerID(), store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -397,10 +406,10 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) { stub.member = tc.member stub.memberStatus = tc.memberStatus stub.roles = tc.roles - cfg := webConfig() + cfg := testConfig() cfg.Discord = discordConfig(srv.URL) cfg.Discord.RequiredRole = tc.require - st, dbURL := newTestStoreURL(t) + st := newTestStore(t) router := newRouter(st, cfg) rr := completeSignIn(t, router, startSignIn(t, router)) @@ -417,7 +426,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) { t.Fatal("a refused sign-in set a cookie") } // The seed owner is still the only Reader, and no session exists. - if n := readerCount(t, dbURL); n != 1 { + if n := len(storeReaders(t, st)); n != 1 { t.Fatalf("readers = %d after a refusal, want 1", n) } }) @@ -428,7 +437,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) { func TestDiscordLoginRequiresRolePositive(t *testing.T) { stub, srv := newDiscordStub(t) stub.roles = []string{"role-1"} - cfg := webConfig() + cfg := testConfig() cfg.Discord = discordConfig(srv.URL) cfg.Discord.RequiredRole = "role-1" router, st := newWebTestServer(t, cfg) @@ -446,32 +455,171 @@ func TestDiscordLoginRequiresRolePositive(t *testing.T) { } } -func TestDiscordLoginRefusesNonOwner(t *testing.T) { - stub, srv := newDiscordStub(t) - stub.ownerID = "someone-elses-snowflake" - cfg := webConfig() - cfg.Discord = discordConfig(srv.URL) +// Registration is the login: a guild member who is not the owner gets their +// own Reader on first sight, and every later sign-in reuses it rather than +// minting a second library. +func TestGuildMemberRegistersOnFirstLoginAndReusesIt(t *testing.T) { + router, st, _ := oauthWebTestServer(t) + if n := len(storeReaders(t, st)); n != 1 { + t.Fatalf("readers before any login = %d, want just the seeded owner", n) + } + + first := signedInReader(t, router, st) + if first == st.OwnerID() { + t.Fatal("a non-owner member's session landed on the owner Reader") + } + readers := storeReaders(t, st) + if len(readers) != 2 { + t.Fatalf("readers after first login = %d, want 2", len(readers)) + } + if readers[1].DiscordID != testOwnerID { + t.Fatalf("registered Reader's discord id = %q, want %q", readers[1].DiscordID, testOwnerID) + } + + second := signedInReader(t, router, st) + if second != first { + t.Fatalf("second login landed on Reader %d, want the existing %d", second, first) + } + if n := len(storeReaders(t, st)); n != 2 { + t.Fatalf("readers after second login = %d, want 2 (no duplicate)", n) + } +} + +// A brand-new Reader's page explains how a library gets filled and offers both +// install links, and the script it serves carries their credential — not the +// owner's. +func TestNewReaderSeesEmptyLibraryAndTheirOwnScript(t *testing.T) { + path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") + if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + _, stubSrv := newDiscordStub(t) + cfg := testConfig() + cfg.Discord = discordConfig(stubSrv.URL) + cfg.UserscriptPath = path router, st := newWebTestServer(t, cfg) - rr := completeSignIn(t, router, startSignIn(t, router)) - if rr.Code != http.StatusForbidden { - t.Fatalf("status = %d, want 403", rr.Code) + cookie := signInCookie(t, router) + reader, _, err := st.GetSession(cookie.Value, time.Now()) + if err != nil { + t.Fatalf("GetSession: %v", err) } - if !strings.Contains(rr.Body.String(), "not the library owner") { - t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200", rr.Code) } - if len(rr.Result().Cookies()) != 0 { - t.Fatal("a refused sign-in set a cookie") + body := rr.Body.String() + for _, want := range []string{ + "Your library is empty", + `href="/install/manga-bookmark.user.js"`, + `href="/install/novel-bookmark.user.js"`, + } { + if !strings.Contains(body, want) { + t.Errorf("empty library page lacks %q", want) + } } - if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" { - t.Fatal("a refused sign-in created a session") + // The Readers panel is the owner's alone. + if strings.Contains(body, `id="readers"`) { + t.Error("a non-owner Reader was shown the Readers panel") + } + + theirCred := readerCredential(testOwnerID) + if theirCred == ownerCredential() { + t.Fatal("test setup: the new Reader's credential collides with the owner's") + } + req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil) + req.AddCookie(cookie) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+theirCred+`"`) { + t.Fatalf("new Reader's script does not carry their own credential:\n%s", got) + } + if strings.Contains(rr.Body.String(), ownerCredential()) { + t.Fatal("new Reader's script carries the owner's credential") + } + if reader.ReaderID == st.OwnerID() { + t.Fatal("the new Reader's session points at the owner") + } +} + +// Only the owner may revoke, and a revocation kills every session that Reader +// holds while leaving everyone else signed in. +func TestOwnerRevokesAnotherReadersSessions(t *testing.T) { + router, st, _ := oauthWebTestServer(t) + theirCookie := signInCookie(t, router) + theirSession, _, err := st.GetSession(theirCookie.Value, time.Now()) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + ownerCookie := sessionCookie(t, st) + + // A non-owner cannot reach the endpoint at all: for them it does not exist. + req := httptest.NewRequest(http.MethodPost, + "/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil) + req.AddCookie(theirCookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("non-owner revoke: status = %d, want 404", rr.Code) + } + if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok { + t.Fatal("a non-owner's revoke attempt still killed the owner's session") + } + + req = httptest.NewRequest(http.MethodPost, + "/readers/"+strconv.FormatInt(theirSession.ReaderID, 10)+"/revoke", nil) + req.AddCookie(ownerCookie) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("owner revoke: status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `id="readers"`) { + t.Fatalf("revoke response did not re-render the roster:\n%s", rr.Body.String()) + } + + // The revoked Reader's next request is rejected; the owner is untouched. + req = httptest.NewRequest(http.MethodGet, "/ui/list", nil) + req.AddCookie(theirCookie) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("revoked session: status = %d, want 401", rr.Code) + } + if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok { + t.Fatal("revoking another Reader took the owner's session with it") + } +} + +// The owner's own page carries the roster; nobody else's does. +func TestOwnerSeesReadersPanel(t *testing.T) { + router, st, _ := oauthWebTestServer(t) + signInCookie(t, router) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie(t, st)) + 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, testOwnerID) { + t.Fatalf("the roster does not list the registered Reader:\n%s", body) + } + if !strings.Contains(body, "Revoke sessions") { + t.Fatal("the roster offers no revocation control for a signed-in Reader") } } func TestDiscordLoginTokenEndpointDown(t *testing.T) { stub, srv := newDiscordStub(t) stub.tokenStatus = http.StatusInternalServerError - cfg := webConfig() + cfg := testConfig() cfg.Discord = discordConfig(srv.URL) router, _ := newWebTestServer(t, cfg) @@ -514,7 +662,7 @@ func TestCallbackRateLimited(t *testing.T) { } func TestLogoutDeletesSession(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) cookie := sessionCookie(t, st) @@ -544,7 +692,7 @@ func TestLogoutDeletesSession(t *testing.T) { } func TestExpiredSessionRejected(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute) if err != nil { @@ -570,7 +718,7 @@ func TestExpiredSessionRejected(t *testing.T) { } func TestBookmarksAPIStillBearerOnly(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) // A session cookie must not grant access to the userscript's JSON API. @@ -591,7 +739,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) { } func TestStaticAssetsServed(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) + srv, _ := newWebTestServer(t, testConfig()) for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) @@ -628,7 +776,7 @@ func uiRequest(t *testing.T, st *store.Store, method, path string, form url.Valu } func TestUIRoutesRequireSession(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) + srv, _ := newWebTestServer(t, testConfig()) cases := []struct{ method, path string }{ {http.MethodGet, "/ui/list"}, {http.MethodPost, "/ui/bookmarks/asura:solo/favorite"}, @@ -647,7 +795,7 @@ func TestUIRoutesRequireSession(t *testing.T) { } func TestFavoriteTogglesWithoutReordering(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -696,7 +844,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) { // rendered attribute's shape — it is not proof the browser accepts the // selector, just a regression guard against reintroducing the bare-id form. func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -722,7 +870,7 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { } func TestChapterOverrideMovesUpdatedAt(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -757,7 +905,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) { } func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -797,7 +945,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) { } func TestChapterOverrideRejectsBadInput(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -821,7 +969,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) { } func TestMutationsOnMissingKey(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) cases := []struct { name string @@ -842,7 +990,7 @@ func TestMutationsOnMissingKey(t *testing.T) { } func TestUIDeleteRemovesRow(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -869,7 +1017,7 @@ func TestUIDeleteRemovesRow(t *testing.T) { } func TestUIListFavouritesTab(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -895,7 +1043,7 @@ func TestUIListFavouritesTab(t *testing.T) { } func TestUIListNewTab(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", @@ -949,7 +1097,7 @@ func seedStatusRows(t *testing.T, st *store.Store) { } func TestTabsShowOnlyTheirBucket(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1006,7 +1154,7 @@ func stripOf(t *testing.T, srv http.Handler, st *store.Store, tab string) string // The strip carries the series with a chapter waiting — the one thing the // updated_at-ordered list below it does not already say — and only on All. func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading @@ -1057,7 +1205,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { // The strip never grows past web.RecentCount, however many series are waiting. func TestRecentStripCapped(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) for i := 0; i <= web.RecentCount; i++ { b := store.Bookmark{ @@ -1088,7 +1236,7 @@ func postStatus(t *testing.T, srv http.Handler, st *store.Store, key, status str } func TestUIStatusSetsBucket(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1107,7 +1255,7 @@ func TestUIStatusSetsBucket(t *testing.T) { } func TestUIStatusRejectsUnknownValue(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1121,7 +1269,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) { } func TestUIStatusRequiresSession(t *testing.T) { - srv, st := newWebTestServer(t, webConfig()) + srv, st := newWebTestServer(t, testConfig()) seedStatusRows(t, st) req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status", @@ -1136,7 +1284,7 @@ func TestUIStatusRequiresSession(t *testing.T) { } func TestUIStatusDoesNotReorderList(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1152,7 +1300,7 @@ func TestUIStatusDoesNotReorderList(t *testing.T) { } func TestCardShowsStatusControls(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1190,7 +1338,7 @@ func TestCardShowsStatusControls(t *testing.T) { } func TestAppRendersNewTabs(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedStatusRows(t, st) @@ -1209,7 +1357,7 @@ func TestAppRendersNewTabs(t *testing.T) { // A mutation has to bring the chrome with it: the strip and the badge live // outside the swapped card, so nothing else would correct them. func TestMutationRefreshesChromeOutOfBand(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", @@ -1255,7 +1403,7 @@ func seedLibraries(t *testing.T, st *store.Store) { } func TestLibrariesAreDisjoint(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedLibraries(t, st) @@ -1286,7 +1434,7 @@ func TestLibrariesAreDisjoint(t *testing.T) { // A row written before the kind column existed has none. It is manga. func TestKindlessRowShowsInMangaLibrary(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seed(t, st, store.Bookmark{ Key: "asura:legacy", Site: "asura", SeriesID: "legacy", @@ -1301,7 +1449,7 @@ func TestKindlessRowShowsInMangaLibrary(t *testing.T) { } func TestNovelPageOmitsUpdatedTab(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedLibraries(t, st) @@ -1332,7 +1480,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) { } func TestMangaPageKeepsUpdatedTab(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedLibraries(t, st) @@ -1350,7 +1498,7 @@ func TestMangaPageKeepsUpdatedTab(t *testing.T) { // tab=new is not offered for novels, so a hand-typed one must land on All // rather than an empty page. func TestNovelNewTabFallsBackToAll(t *testing.T) { - cfg := webConfig() + cfg := testConfig() srv, st := newWebTestServer(t, cfg) seedLibraries(t, st) diff --git a/docker-compose.yml b/docker-compose.yml index e30d4b3..05cd2ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,19 +16,17 @@ services: # TOKEN_KEY derives every Reader's userscript credential (issue #24) — # compose refuses to start without it. TOKEN_KEY: ${TOKEN_KEY:?set TOKEN_KEY in .env} - # Retired global credential, optional: only used until the grace - # deadline, for already-installed scripts. Remove after the window. - API_TOKEN: ${API_TOKEN:-} - API_TOKEN_GRACE_UNTIL: ${API_TOKEN_GRACE_UNTIL:-} - # Owner's Discord user ID — required, seeds the one Reader row. + # Owner's Discord user ID — required. Seeds the owner Reader (the + # administrator); every other Reader registers on their first login. OWNER_DISCORD_ID: ${OWNER_DISCORD_ID:?set OWNER_DISCORD_ID in .env} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net} # The bookmarks database. Host is the compose service name; the password # comes from .env so it is never committed. DATABASE_URL: ${DATABASE_URL:-postgres://bookmarks:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/bookmarks?sslmode=disable} PORT: "8080" - # Discord OAuth for the browser UI (issue #23). The first four are + # Discord OAuth for the browser UI (ADR-0002). The first four are # required; DISCORD_REQUIRED_ROLE is optional and empty by default. + # Guild membership is the whole gate: any member becomes a Reader. DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID in .env} DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?set DISCORD_CLIENT_SECRET in .env} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:?set DISCORD_GUILD_ID in .env}