13e8e73da7
The browser UI signs in with a Discord authorization code grant (identify + guilds.members.read) instead of a shared password. Guild membership is the gate; the owner's Discord ID is the only identity allowed in while registration is closed. Sessions become rows in a sessions table with opaque random ids — the cookie carries only the id, looked up and expiry-checked per request — so deleting a row revokes a session. HMAC cookie signing, its derived key, and WEB_PASSWORD are gone, and no replacement signing secret is introduced (ADR-0002). Discord's API base is configurable (DISCORD_API_BASE); the full flow is tested through the real router against a local stub, including the form-encoded token exchange Discord rejects if sent as JSON.
8.5 KiB
8.5 KiB
Guidance for OpenCode (and Claude Code) working under backend/. See root AGENTS.md for the project-wide architecture diagram, hard constraints, and design system.
- Backend (
backend/): stdlibnet/http(handful routes, no framework) + Postgres overjackc/pgx/v5(pure Go,CGO_ENABLED=0-> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain:8080. Single binary, split into packages underbackend/internal/:store(Bookmark type, Postgres persistence, migration runner),latest(background poller, site parsers, TLS fetcher),session(cookie signing, login rate limiter),httpmw(Auth/Gzip/CORS middleware),api(JSON bookmark handlers),userscript(userscript-serving handler),web(browser UI handler +templates/+static/,go:embed-ed).backend/main.gois the composition root — the only place that wires packages together intonewRouter. Root-level*_test.gohold integration tests that exercise the full router; unit tests for a package live beside it underinternal/. - Schema is migration-owned.
internal/store/migrations/*.sqlisgo:embed-ed and applied on every start bystore.migrate: one numbered file per change, one transaction each, versions recorded inschema_migrations. Files are append-only — editing an applied one changes nothing on a database that already ran it. No column probing, no data-fixup migrations: both were SQLite-era machinery and are gone. - Tests need Docker.
internal/pgteststarts onepostgres:17-alpinecontainer 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 thatTestMain. - Single-owner store, three tables.
readersis keyed by Discord user ID and carries the SHA-256 of the owner's userscript token (the globalAPI_TOKENtoday; issue #22). The seed creates exactly one row at startup.serieskeyed(site, series_id)(asura|demonic|comix|kagane|novelfull|lightnovelworld) owns the shared facts — title, cover, canonical URL,kind(manga|novel), Latest Chapter,latest_checked_at— andbookmarksholds only what differs between readers: progress, favourite, lifecycle bucket,updated_at. A bookmark is keyed(reader_id, site, series_id)— no surrogate id; the wirekeyis derived assite:series_idon read — and every store read/write is scoped to the reader it names.Store.OwnerID()is the seeded owner, which every handler passes while the global token is still the only credential. Sync last-write-wins; the wire format stays flat (ADR-0004).Store.Upsertdecomposes one flat body across two tables and enforces the ownership rule: clienttitle/series_url/coverare written only when the series row is new (ADR-0003). - Endpoints:
GET /bookmarks,PUT /bookmarks/{key}(upsert; seeupdated_atrule below),DELETE /bookmarks/{key},GET /healthz(no auth). - Web UI: same binary serve the browser UI on a second
hostname —
GET /(list, or login page when no session),GET /auth/discord+GET /auth/discord/callback(Discord OAuth, ADR-0002),POST /logout,GET /static/*, htmx fragment endpoints under/ui/*. Templates + assetsgo:embed-ed underbackend/internal/web/, sobackend/Dockerfilemust copy the wholeinternal/tree, not just*.go. Sessions are rows in thesessionstable: 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 throughStore.Get+Store.Upsertsoupdated_atrule stays one place. Seedocs/superpowers/specs/2026-07-25-web-ui-design.md. Design-tool caveat: templates link/static/style.cssroot-absolutely (correct — served from/), but impeccable detector resolves stylesheet href withpath.resolve(fileDir, href), drops directory on leading/and silently skip file. Relative href don't help either: template's directory isn't its served path. Sodetect.mjs backend/internal/web/templatesreports false clean — always passbackend/internal/web/statictoo. One finding there,overused-fonton "Instrument Serif", deliberate identity choice, not debt. - Every action that moves series out of list is confirm-gated.
Archive, finish, remove each open own
.confirm-rowdisclosure (toggleConfirmRow(key, kind)infilter.js,kind∈archive|finish|remove); restore fire instantly since it's the reversal. Remove's row wear ember wash, two reversible ones wear.calmgrey.--emberstay reserved for new-chapter signal: busy bar and inline error use--mute. - Latest-chapter poller: ticker goroutine in same binary re-check
each bookmarked series' newest published chapter from backend's own
network access, so
latest_chapterstay fresh when user not browsing. Second, parallel signal — userscript keep ownmaybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic unchanged. Two independent clocks: per-series cooldown (series.latest_checked_at, enforced byStore.DueForLatestCheck's WHERE clause) and wake interval. The poller walks Series, not Bookmarks — a series referenced by several bookmarks is fetched once per cycle, and the due queue ordersreader_count DESC, latest_checked_at ASC(ADR-0003). Series row stamped before fetch so broken series wait out full cooldown instead of retrying every tick; found chapter written straight to the series row viaStore.SetLatestChapter, so a bookmark'supdated_at— and the list order — is never touched. Fetches usebogdanfinn/tls-clientwith Chrome profile as defence in depth against fingerprint-based blocking; any failure log and skip. kagane and novelfull sit behind Cloudflare JavaScript challenges the TLS client can't clear, so they are browser-only: fetched over CDP viaBROWSER_WS_URL, and simply not polled when that's unset. Seedocs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md. The poller's series write is a single-column UPDATE (Store.SetLatestChapter), not a read-modify-write of the whole bookmark: it cannot revert read progress or moveupdated_at, so the old stale-re-read race is gone with the Get+Upsert flow. updated_atdrives list order, so moves only on real reading progress: server apply its timestamp when row new orlast_chapter_numchanges, else keep stored value — favouriting series or recording newly published chapter must not reorder list.PUTtherefore returns row as stored, clients must adopt that response rather than own payload. Seeplans/2026-07-25-bookmark-list-favorites-design.md§4.- Lifecycle buckets:
statuson each bookmark isreading|archived|finished, orthogonal tofavorite. Archived and finished appear only in own tab — not in All, Updated, Favourites, or recent strip. Poller keeps checking archived series and skip finished ones.finishedsettable only from web UI;PUT /bookmarks/{key}reject it with 400. Empty incoming status means "keep stored one" — resolved on theVALUESside ofStore.Upsert, not conflict clause, sinceexcluded.*is post-evaluation row and default applied there would wipe bucket on every PUT from client that predates column. Seedocs/superpowers/specs/2026-07-27-status-buckets-design.md. - Config via env:
API_TOKEN,OWNER_DISCORD_ID(seeds the owner Reader; required),ALLOWED_ORIGINS(comma list),DATABASE_URL(Postgres connection URL, required — no default),PORT(default8080),DISCORD_CLIENT_ID/_CLIENT_SECRET/_GUILD_ID/_REDIRECT_URI(required; Discord OAuth for the browser UI),DISCORD_REQUIRED_ROLE(optional role gate, empty by default),DISCORD_API_BASE(defaulthttps://discord.com/api/v10),LATEST_CHAPTER_POLL_ENABLED/_COOLDOWN/_INTERVAL/_BATCH/_STAGGER(background latest-chapter poller; defaults on,1h/10m/14/20s).USERSCRIPT_PATHandNOVEL_USERSCRIPT_PATH(files served at/u/{token}/manga-bookmark.user.jsand/u/{token}/novel-bookmark.user.js, defaults/userscript/manga-bookmark.user.jsand/userscript/novel-bookmark.user.js, both supplied by bindmount).BROWSER_WS_URL(headless-shell CDP endpoint for kagane and novelfull; unset disables browser polling and leaves those sites to the userscript alone).