Serves the userscript from the backend so Violentmonkey auto-updates it, plus two panel fixes.
## Backend: `GET /u/{token}/manga-bookmark.user.js`
The script is read off disk per request from `USERSCRIPT_PATH` and streamed back with its `@version` line rewritten.
- **Token in the path, not a header.** Violentmonkey's update poll sends no `Authorization` header, and the script embeds `API_TOKEN` in plain text — an open URL would hand that token to anyone who guessed it. Compare is constant-time.
- **404, never 401**, for both a wrong token and a missing file: a prober learns nothing about whether the route exists.
- Registered outside `withAuth` and outside the `WEB_PASSWORD` gate, so the script is installable on a deployment that never enabled the web UI.
- Stdlib only (`crypto/subtle`, `os`, `regexp`) — no new Go dependencies.
**The served `@version` is derived from the file's mtime** (`YYYY.MM.DD.HHMM`, UTC), discarding whatever the file body says. Violentmonkey only updates when the served version sorts higher than the installed one, so a body-derived version means one typo or accidental downgrade freezes updates forever. An mtime-derived version is monotonic by construction. A file with no `@version` line is served byte-identical. `os.Stat` runs before `os.ReadFile`, so a concurrent edit can only serve new content under an old stamp — which self-heals on the next poll — never the reverse.
## Bindmount
`./userscript` is bindmounted read-only at `/userscript`. The script is deliberately **not** copied into the image: the build context stays `./backend`, and widening it would churn every `COPY` path for a file the mount always supplies. Editing the file on the VPS is live on the next poll — no rebuild, no restart. `git pull` restores the committed version, so a redeploy always ships the repo's script; checkout sets mtime to now, so even a rollback serves a *higher* version and is adopted. Without the mount the endpoint 404s and logs it; bookmark sync is unaffected.
`@downloadURL` / `@updateURL` are literal URLs in the metadata block — it is parsed before any JS runs, so `API_BASE`/`API_TOKEN` cannot be interpolated. The token was already committed in this file, so this adds no new exposure.
## Userscript UI
- **Card actions moved under the subtitle.** Only the cover and the title continue reading now; the subtitle and the action row are inert siblings in the text column. A thumb that misses ★ lands on nothing, and Remove is never inside a link.
- **Loading spinner** while the first fetch is in flight — the panel used to read as frozen on the first open after a cold start. It draws only when there is nothing cached to draw instead, so a populated list never flaps.
## Verification
- `go test -count=1 ./...` — ok, 7.070s
- `node --check` clean; `node --test userscript/test/logic.test.js` — 14/14
- Live `docker compose` smoke: `/healthz` 200, wrong token 404, script served with a stamped `@version 2026.07.28.1057` and both metadata URLs present; `touch`ing the file advanced the served version to `2026.07.28.1100` with no restart.
Layout and spinner are verified on-device — there is deliberately no DOM test harness.
Reviewed-on: #8
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
12 KiB
AGENTS.md
Guidance for OpenCode (and Claude Code) working in this repo.
Status
Active. Backend (backend/) and userscript (userscript/manga-bookmark.user.js) built. Plan plans/mangaBookmark.md = original spec, may drift; trust code + design docs in docs/superpowers/specs/ over plan.
What this is
Manga read-progress tracker for user reading on asurascans.com (current domain; asuracomic.net 301s here) and demonicscans.org from Bromite (mobile Chromium). Userscript injects on-page UI (floating button + slide-in panel), syncs progress to self-hosted Go backend so bookmarks unify across both sites and devices.
Hard constraints (drive design — do not violate)
Bromite uses Chromium's native userscript engine, not Tampermonkey:
- No
GM_*APIs anywhere. NoGM_setValue/GM_getValue(use pagelocalStorage), noGM_registerMenuCommand(inject on-page UI), noGM_xmlhttpRequestfor cross-origin (use plainfetch()). GM-free script also runs in desktop Tampermonkey/Violentmonkey for faster iteration. - Cross-origin
fetch()works only against CORS-enabled backend. Manga siteshttps://, so backend must be HTTPS (else mixed-content block). - Asura and Demonic = separate origins, separate
localStorage— shared remote store only way to unify bookmarks. Cloud sync required, not optional. - Userscript runs in isolated world, so embedded API token safe from site's JS.
- Cloudflare's block on manga sites is IP-reputation-based, not universal — not reliably reproducible. Verified 2026-07-26: plain
curlfrom both CGNAT dev machine and deployed VPS got clean 200s w/ real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier, untested assumption CGNAT dev IP would be blocked; wasn't, at least this date. Treat "does curl work now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare bot scoring can flip clean IP without notice. Any backend fetcher still needs graceful-degrade path for when challenged; adapters should be verified against live pages (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
Architecture
Bromite userscript (isolated world, per-site adapters, localStorage cache)
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
- Backend (
backend/): stdlibnet/http(handful of routes, no framework) +modernc.org/sqlite(pure Go,CGO_ENABLED=0-> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain:8080. - Single-user store. One
bookmarkstable keyed<site>:<series_id>(asura|demonic). Sync last-write-wins. Schema + endpoint list in plan. - Endpoints:
GET /bookmarks,PUT /bookmarks/{key}(upsert; seeupdated_atrule below),DELETE /bookmarks/{key},GET /healthz(no auth). - Web UI: same binary serves password-gated browser UI on second
hostname —
GET /(list, or login page when no session),POST /login,POST /logout,GET /static/*, htmx fragment endpoints under/ui/*. Templates/assetsgo:embed-ed, sobackend/Dockerfilemust copytemplates/andstatic/plus*.go. Sessions = stateless HMAC cookies keyed offAPI_TOKEN;WEB_PASSWORDgates them, when empty web routes not registered at all. UI mutations read-modify-write throughStore.Get+Store.Upsertsoupdated_atrule stays one place. Seedocs/superpowers/specs/2026-07-25-web-ui-design.md. - Latest-chapter poller: ticker goroutine in same binary re-checks
each bookmarked series' newest published chapter from backend's own
network access, so
latest_chapterstays fresh when user not browsing. Second, parallel signal — userscript keeps ownmaybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic unchanged. Two independent clocks: per-bookmark cooldown (latest_checked_atcolumn, enforced byStore.DueForLatestCheck's WHERE clause) and wake interval. Row stamped before fetch so broken series waits full cooldown instead of retrying every tick; writes go throughStore.Get+Store.Upsertso new chapter never reorders list. Fetches usebogdanfinn/tls-clientw/ Chrome profile as defence in depth against fingerprint-based blocking; any failure logs and skips. Seedocs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md. Poller'sStore.Get+Store.Upsertnot wrapped in transaction, so userscriptPUTcommitting between the two can be overwritten by poller's stale re-read — reverting read progress and, since stored value now differs, movingupdated_atand reordering list. Known, accepted limitation for single-user deployment, not bug to fix. updated_atdrives list order, moves only on real reading progress: server applies timestamp when row new orlast_chapter_numchanges, else keeps stored value — favouriting series or recording newly published chapter must not reorder list.PUTtherefore returns row as stored; clients must adopt that response over 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 All, Updated, Favourites, or recent strip. Poller keeps checking archived series, skips finished ones.finishedsettable only from web UI;PUT /bookmarks/{key}rejects it w/ 400. Empty incoming status means "keep stored one" — resolved onVALUESside ofStore.Upsert, not conflict clause, sinceexcluded.*= post-evaluation row and default applied there'd wipe bucket on every PUT from client predating column. Seedocs/superpowers/specs/2026-07-27-status-buckets-design.md. - Config via env:
API_TOKEN,ALLOWED_ORIGINS(comma list),DB_PATH(default/data/bookmarks.db),PORT(default8080),WEB_PASSWORD(gates browser UI; unset disables it),LATEST_CHAPTER_POLL_ENABLED/_COOLDOWN/_INTERVAL/_BATCH/_STAGGER(background latest-chapter poller; defaults on,1h/10m/14/20s).USERSCRIPT_PATH(file served at/u/{token}/manga-bookmark.user.js, default/userscript/manga-bookmark.user.js, supplied by a bindmount).
Userscript structure (single IIFE, manga-bookmark.user.js)
- Site adapters — one per host,
detect(location, document)returns pagetype+ IDs. ID type/IDs from URL regex (most stable); pulltitle/coverfromog:title/og:imagemeta tags, not CSS classes. - API client —
apiGet/apiPut/apiDeletew/ bearer header;localStoragekeymangabm:cachefor instant render + offline fallback. - Progress logic — auto-upsert
last_chapteronly whenchapterNum >= stored last_chapter_num(re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. - Retry queue — every write goes through
pushBookmark/pushDelete, so failed mutation parked inlocalStorage(mangabm:queue) and replayed on next navigation, reconnect, orrefresh(). Entries are markers ({key, op, sendStatus, attempts}), never payloads — body read from cache at send time, so one entry per key gives ordering + coalescing for free.sendStatussticky: while archive pending, later writes to that key keep carrying bucket, stops successful in-between write from silently un-archiving series.refresh()drains before fetching, overlays anything still pending, so list never flaps. 400 drops entry, 401 aborts pass and keeps queue, transient failures retry to cap of 10. Latest-chapter writes deliberately stay out of queue. Seedocs/superpowers/specs/2026-07-27-offline-retry-queue-design.md. - UI — rendered inside Shadow DOM root to isolate from site CSS
(critical on mobile). Three tabs (All / Favourites / Archived) + row of
link chips to web UI and both manga sites;
WEB_BASEsits in CONFIG block next toAPI_BASE. - SPA navigation — Asura is Astro, client-routed on comic/chapter pages: patch
history.pushState/replaceState+ listenpopstate, re-rundetect()on URL change so auto-update fires w/o reload. Demonic uses classic reloads (initialdocument-idlerun suffices).
Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
- asurascans.com: series
/comics/<slug>(slug carries a trailing site-wide build-hash suffix, e.g.-059befe1, that rotates on every redeploy), chapter/comics/<slug>/chapter/<n>.seriesIdmust strip the hash (/-[0-9a-f]{8}$/,stripBuildHashin the userscript,asuraBuildHashin the backend); URLs keep the full slug — stale-hash URLs 302 to current ones. Astro-rendered; chapter links present in raw server HTML. - demonicscans.org: series
/manga/<slug>(slug may URL-encode punctuation, e.g.%2527for'), chapter/title/<slug>/chapter/<n>/<page>(olderchaptered.php?manga=<id>&chapter=<n>form still exists as redirect, what series-page chapter-list anchors link through). Encodings (incl. triple-encoded punctuation like%25252D) are identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28.
Commands
Backend (cd backend):
- Test all:
go test ./... - Single test:
go test -run TestName ./... - Build static binary:
CGO_ENABLED=0 go build
Local stack: docker compose up (named volume mounted at /data, restart: unless-stopped).
Smoke test: curl endpoints w/ Authorization: Bearer <token>; confirm OPTIONS preflight returns CORS headers and /healthz returns 200.
Forge: Gitea, not GitHub
origin = self-hosted Gitea instance (gitea.violetcrown.my.id), so gh doesn't work here — use tea (Gitea CLI) for anything past plain git. Common ones:
- Open PR:
tea pr create --head <branch> --base main --title "..." --description "..." - List / view / check out:
tea pr list,tea pr <n>,tea pr checkout <n> - Issues:
tea issue create,tea issue list - Auth lives in
tea login, notGH_TOKENenv var.
tea prints output as rendered boxes not plain text; PR URL lands on last line.
Security invariants
- Auth on
/bookmarks*: requireAuthorization: Bearer <API_TOKEN>, constant-time compare, 401 otherwise. - CORS: reflect
Originonly when inALLOWED_ORIGINS; allowGET,PUT,DELETE,OPTIONS+ headersAuthorization,Content-Type; answer preflightOPTIONSw/204.
Relevant skills
multi-stage-dockerfile and docker-compose-orchestration for container work (referenced in plan).
graphify
Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.
Rules:
- For codebase questions, first run
graphify query "<question>"when graphify-out/graph.json exists. Usegraphify path "<A>" "<B>"for relationships,graphify explain "<concept>"for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
- After modifying code, run
graphify update .to keep graph current (AST-only, no API cost).
OpenCode-specific
- Caveman mode active by default (
/home/tan/.config/opencode/AGENTS.md). Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normal. .superpowers/and.agents/dirs hold skill definitions. Gitea atgitea.violetcrown.my.id.