Appearance
@libris/api-hono
2.1.0
Minor Changes
5cbbe9c: The API now exports traces, not only logs.
otel.tsstarts theNodeSDKwith@opentelemetry/auto-instrumentations-node, so a request produces spans for the HTTP server (Hono rides onnode:http), Postgres and Redis.fsanddnsinstrumentation stay off — one span per syscall would bury the request path. With an active span, the existing logger plugin addstrace_idandspan_idto each log record, so a trace backend can link a span to its log lines.Behaviour is unchanged without
OTEL_EXPORTER_OTLP_ENDPOINT: the SDK never starts. Traces are also suppressed underNODE_ENV=test, as before.
2.0.2
Patch Changes
- 4fd31df: Release tooling: move to
@changesets/cliv3 to matchchangesets/actionv2, which Dependabot bumped and which refuses CLI v2. The config now opts private packages back into versioning (privatePackages.version), since v3 stopped versioning them by default and every workspace here is private.
2.0.1
Patch Changes
762d6cf: Stop a book's
possibleDuplicatelink from leaking another user's private upload. The metadata worker writes the duplicate id with no owner predicate, and the inbox detail route resolved and returned it for any caller, exposing the target's title, author and status (and its existence). The lookup now follows the same visibility rule as the rest of the inbox — own books plus the shared organized library, everything for admins — and the rawpossibleDuplicateOfFK is no longer part of any API response; only the resolved, visibility-checked object is.762d6cf: Take EPUB metadata rewriting off the API event loop. The OPF scans in
embed-metadatanow use the same single-pass XML scanner as the metadata extractor instead of regexes that backtracked quadratically (measured 7.3 s for a 160 KiB crafted OPF), OPF documents overLIBRIS_MAX_EMBED_OPF_BYTES(default 1 MiB) are refused before rewriting, and the whole rewrite — including the synchronous DEFLATE recompression — runs in anode:worker_threadsworker with a hardLIBRIS_EMBED_TIMEOUT_MS(default 30 s) timeout. A worker crash or timeout leaves that book un-embedded and the organize job continues instead of stalling every HTTP request.762d6cf: Drain delayed jobs from a queue.
POST /api/jobs/queues/{name}/draindocumented removing waiting and delayed jobs, but called BullMQ'sdrain()withouttrue, leaving scheduled jobs behind.762d6cf: Make job browser pagination honest and bounded.
GET /api/jobsderivedtotalandtotalPagesfrom the first 200 jobs per queue, so a queue with more jobs reported a truncated total and deep pages came back empty with no explanation.totalnow comes from BullMQ's counters (exact); each selected queue/status board is read in its native order up to its share of a 10,000-job window, jobs are attributed to the board they came from (no per-jobgetState()round trip), and the merged window is ordered by creation time with a deterministic queue/id tie-break. Pages past the window are answered from the counters without touching Redis, and the response carries atruncatedflag — surfaced in the settings browser — when any board holds more matching jobs than the window reaches.762d6cf: Close two KoSync brute-force gaps.
POST /kosync/users/authis now bucketed by the username in the JSON body — the value the handler verifies — instead ofx-auth-user, so a header that disagrees with the body can no longer buy a fresh budget per guess. The progress routes (GET/PUT /kosync/syncs/progress) now share the per-user credential budget, with only failed checks counted: a locked-out identity is refused with 429 before verification, a device that keeps syncing never spends the budget, and a verified check clears recorded failures.762d6cf: Stop logging raw Better Auth keys when Redis degrades. A key embeds live material — a session token, a single-use verification token, or
<ip>|<path>for a rate-limit counter — and a log line is a copy kept longer and read more widely than Redis. Failure warnings now carry a category (credential,rate-limit,active-sessions) and a truncated SHA-256 so lines still correlate without exposing the secret.762d6cf: Bound the endpoints that enqueue metadata and Hardcover work.
POST /api/library/{id}/refetchandPATCH /api/inbox/{id}/rescannow share a deterministic job id (repeat clicks collapse into the in-flight job) and a per-user cap of 10 in-flight metadata jobs, returning 429 beyond it;POST /api/hardcover/syncdedups on a per-user job id and answers "Sync already queued" instead of adding another job, and it now uses the registered scheduler queue rather than opening and closing a Redis connection per request (503 if that queue is not running).GET /api/hardcover/statuscaches the live token verification per user for 30 seconds, and connecting or disconnecting a Hardcover credential clears that cache.762d6cf: Fix Redis URL credential parsing: an ACL username in
REDIS_URL(fromREDIS_USER) is now passed to ioredis, and percent-encoded usernames and passwords are decoded, so credentials containing reserved characters authenticate.762d6cf: Prevent data loss in the book-organize and cleanup workers, and fix inbox navigation after client-side route changes.
- Organize now gives each book its own id-suffixed library directory and moves files with a no-clobber operation, so two books with the same author/title/filename can no longer overwrite each other; a retry after an interrupted move adopts a destination only when its bytes match a recorded hash — the upload checksum or the embedded content hash — so a re-organize interrupted after metadata embedding also recovers.
- Forced cover re-download no longer deletes the existing cover before the replacement has been fetched.
- A failed EPUB metadata rewrite removes its
.tmpleftover, and a re-organize removes the old directory's cover once no other book references it. - Cleanup only deletes a
book_filesrow on a confirmedENOENT/ENOTDIRand keeps (and reports) records it cannot read. - The inbox detail page derives its book id reactively, resets per-book state on navigation, and filters server events by the book currently on screen, so rescan/approve/delete reach the right book.
762d6cf: Treat admin as a membership test rather than an exact role match. Better Auth stores multiple roles as a comma-joined string, so a user with
admin,useris now counted as an active admin by the last-admin guard (SQL and session checks), granted admin routes, and shown as an admin in the settings user list. Previously the two-step demotionadmin→admin,user→usercould lock the sole admin out of the install.762d6cf: Keep reading-statistics baselines when the sample's book was deleted, and stop a zero-delta day from seeding an empty velocity chart. Deleting a book sets its progress-history
book_idto NULL; an inner join dropped that row, so the first in-period sample was counted from zero a second time (a book at 50% on Dec 31 and 51% on Jan 1 reported 51 pages instead of 1). The baseline queries now left-join the book, and the moving-average bounds only consider days with a positive delta. Adds an index on(user_id, document, device, created_at DESC)for the per-stream baseline lookup, which otherwise sorted the user's whole history on every cache miss.762d6cf: Average reading velocity over calendar days. The 7-day moving average slid over days that had syncs, so a week with reads on only two days divided by two instead of seven and idle days were missing from the series entirely; the window is now filled from a
generate_seriescalendar, starting six days before the first day with a read. An install with no reads still reports an empty series rather than 90 zeroes.762d6cf: Rank
GET /api/stats's genre distribution by numeric book count. The query ordered by the serialized text alias, so with ten or more genres a count of 9 outranked 10 and the top-ten cutoff could drop a more popular genre; ties now break deterministically by name.762d6cf: Fix stats period boundaries double-counting progress. Page deltas for the heatmap and reading velocity were computed after filtering history to the display period, so the first sync of a period had no previous sample and its whole percentage counted as pages read (a 100-page book at 50% on Dec 31 and 51% on Jan 1 reported 51 pages instead of 1). Each
(document, device)stream now carries its last pre-period sample as the LAG baseline, with only in-period rows aggregated.762d6cf: Compare the full canonical origin (scheme, host, and port) for cookie-authenticated mutations and WebSocket upgrades. A same-host origin on a different port — which
SameSitecookies cannot separate, since they are site-scoped rather than port-scoped — is now rejected with 403, and valid IPv6 origins such ashttp://[::1]:3000are accepted instead of rejected. The scheme comes fromx-forwarded-protoonly whenTRUST_PROXY_HEADERS=1; reverse proxies should forward it (see Reverse Proxy in the deployment docs).762d6cf: Make search tolerant of punctuation. All four search endpoints (/api/search, /api/library, /api/inbox, /opds/search) shared a sanitizer that stripped most tsquery operators but not the apostrophe, so a query such as
foo&'built a tsquery Postgres rejects and answered 500. They now sharebuildPrefixTsquery, which drops apostrophes sochildren'sstill reaches the English stemmer as one word and replaces every other syntax character (including-, which silently meant "phrase") with a separator, returning null — and therefore an unfiltered or empty result — when nothing searchable remains.762d6cf: Bind the OpenAPI-only tooling server to loopback.
openapi-server.tsserves a router with no auth middleware (started bybruno-import.sh), andserve()without a hostname listened on every interface; it now binds127.0.0.1so the tooling port is not reachable from the network.762d6cf: Bound inbox uploads before multipart parsing.
POST /api/inbox/uploadnow enforces an aggregate byte cap as the body streams — so a chunked request cannot buffer past it — and a file-count cap; both are configurable throughLIBRIS_MAX_UPLOAD_BYTES(default 1 GiB) andLIBRIS_MAX_UPLOAD_FILES(default 20). Previously onlyContent-Length-declared, per-file sizes were checked after the whole request had already been buffered in memory. Also upgrades Hono to 4.13.8, resolving three moderate advisories (including unbounded dot-notation nesting inparseBody).762d6cf: Cap inbound WebSocket frames at 64 KiB. The event socket only acts on the literal
"ping"text, butwsdefaults to a 100 MiB frame limit, so an authenticated client could make the process buffer hundreds of MiB across its allowed sockets and have every byte discarded. Oversized frames now close the connection with code 1009.
2.0.0
Major Changes
1b6c424: Multi-user authentication, and a security pass over everything it touched.
Libris now has real user accounts. Authentication moves from a single shared API key to Better Auth: people sign in with an email and password, admins manage other accounts, and e-readers and scripts authenticate with per-user app passwords instead of one install-wide key. Books, reading progress, inbox uploads and third-party credentials all belong to a person now, so an install can be shared without everyone seeing everyone else's things.
Breaking
Read the "Upgrading to the Better Auth Release" runbook in
docs/deployment.mdbefore deploying. In short:BETTER_AUTH_SECRETis required. Generate it withopenssl rand -base64 32. Published placeholders and low-diversity values are rejected at startup, so a copied.env.examplewill not boot.BETTER_AUTH_URLis required whenNODE_ENV=production, and must be the public origin the browser actually reaches — scheme and host only, no path. Better Auth does not infer an HTTPS origin behind a TLS-terminating proxy; it reads the container's plain-HTTP socket, so leaving this unset makes every browser sign-in fail with403 INVALID_ORIGIN.COOKIE_DOMAINis gone. The session cookie is host-only, so Libris must be served from a single origin. The variable is now ignored rather than rejected, so a compose file that still sets it gets no error — delete it.- Everyone is signed out by the upgrade, and API keys stop working. Re-pair e-readers with app passwords minted under Settings → Connections.
- KoSync and OPDS credentials must be regenerated. OPDS now authenticates with an account email plus an app password.
- Rotating
API_SECRET_KEYnow invalidates every KoSync credential, because stored secrets are peppered with a key derived from it. Every paired reader has to be paired again after a rotation. - Unbanning does not restore app passwords. Banning disables the user's app passwords; lifting the ban leaves them disabled and the user must mint new ones.
- Upload API: a file already in the library is reported in a new
skipped[]array rather thanerrors[], and a batch in which every file was already present now answers200instead of400. - OpenAPI: the
BookApprovedResponsecomponent is replaced byBookUpdated(a superset of its fields — no client loses anything).
Upgrading an install that predates this release does not need SQL: the first-run form adopts the existing account, so an operator signs in through it and sets a password.
Added
- Sign-in, first-run admin setup, and an Account tab for changing your own name and password, listing the devices you are signed in on, and revoking them.
- A Users tab for admins, with the last admin protected from demotion, ban and deletion through every path that could otherwise strip them.
- Per-user Hardcover accounts. Previously only one user per install could connect one, and a scheduled sync spent whichever token sorted first; the install-wide phase now uses an admin's token and skips, loudly, if no admin has connected.
GET /api/health/live, an I/O-free liveness probe for container health checks.GET /api/healthkeeps its existing readiness semantics and its response shape.- The realtime event socket now follows the session: it closes when the session behind it is revoked, and re-dials to be re-scoped when your role changes rather than signing you out.
Fixed and hardened
Ownership is now enforced on every read surface — inbox, dashboard counts, search suggestions, uploader attribution and library facets no longer leak other users' pre-approval uploads or raw user ids. Rate limiting buckets IPv4 clients individually, applies a per-credential budget to KoSync and sign-in, and bounds the request body before it is parsed. EPUB parsing runs in linear time and refuses archives crafted to exhaust memory. Redis is treated as a cache rather than the authority on who is signed in, so an outage degrades instead of signing everyone out. Remote cover fetching, ingestion paths, WebSocket upgrades and proxy-derived client addresses are all validated at their boundaries.
1.1.2
Patch Changes
11664da: Make the unit-test tasks cacheable again.
Both
@libris/api-hono#testand@libris/web#testwere reported by Vite Task asnot cached because they modified their inputson every single run — locally and in CI. The cause: loading a TypeScriptvite.config.tsmakes Vite write a transient.mjsintonode_modules/.vite-temp/, import it, then delete it. Because both tasks declareinput: [{ auto: true }], which tracks the whole package directory, that write lands inside the tracked input set and the task fingerprint changes mid-run.Excluding
node_modules/.vite-temp/**from both tasks fixes it. A repeatvp run -r testwith no changes now reports 3/3 cache hit and finishes in 0.19s instead of re-running the full suite (~73s locally, ~5 minutes on a 2-core CI runner, since the api-hono suite is 481 tests each bootstrapping a PGlite instance).75bb9af: Port CI/CD from Forgejo Actions to GitHub Actions, and publish images to GHCR.
.github/workflows/ci.ymlreplaces.forgejo/workflows/ci.yml: jobs move toubuntu-latest,setup-vploses its Forgejo-only full-URL form, and the artifact actions go to v4 (v3 was shut down on github.com in January 2025). The separatee2e-prande2e-mainjobs are merged into onee2ejob that selects the@smokesubset on pull requests and the full suite on pushes, so the service, container, and env setup can no longer drift between them. Shards go from 2 to 3, and aconcurrencygroup cancels superseded runs. The bespoke PR-comment step that called the Forgejo issues API is dropped — GitHub's native checks UI already reports per-shard status..github/workflows/release.ymlreplacespublish-images.ymland switches the release strategy tochangesets/action@v1. Releases are now PR-gated: merging to main opens a "chore: version packages" PR, and merging that is what triggers the release. The action runs without apublishinput, since no workspace goes to npm.The old two-job split existed only because the Forgejo runners were split — one had Node without a Docker daemon, the other a daemon without Node. A GitHub runner has both, so the
ci/release-<run_id>staging branch, the re-clone, and the rebase-onto-main are all gone. Publishing is idempotent via the existing composite-tag registry check, so no commit-message sniffing is needed to detect the version merge. Registry auth uses the built-inGITHUB_TOKEN, retiring theREGISTRY_TOKENsecret, and the build gains buildx with a GitHub Actions layer cache.Docs and agent instructions follow:
docs/ci-cd.mdis rewritten for the new workflows and documents the release flow,docs/deployment.mdpoints atghcr.io/razorsim/libris, andfjis replaced byghthroughout AGENTS.md, CLAUDE.md, README.md, anddocs/contributing.md.75bb9af: Upgrade Vite+ from 0.1.24 to 0.2.6 and move the Node toolchain to 26.5.0.
Vite+ 0.2.0 dropped
@voidzero-dev/vite-plus-test(its rebundled Vitest copy) and now runs upstream Vitest, pulled in transitively byvite-plus. The workspace catalog no longer aliasesvitestto the removed wrapper, and thevitestoverride / peer-dependency rules that existed only to serve that alias are gone.import ... from "vite-plus/test"is unaffected.Toolchain moves with it: Vite 8.0.16 -> 8.1.5, Rolldown 1.0.3 -> 1.2.0, Vitest 4.1.8 -> 4.1.10 (includes the GHSA-p63j-vcc4-9vmv fix), oxfmt 0.52 -> 0.60, oxlint 1.67 -> 1.75, oxlint-tsgolint 0.23 -> 7.0.2001 (stable tsgolint 7), tsdown 0.22.1 -> 0.22.13.
Node 26 pins brought in step with the runtime:
.node-versionpinned to26.5.0(was the floating major26)- Docker images pinned to
node:26.5.0-slim, withpnpm@11.5.0matchingpackageManagerandvite-plus@0.2.6pinned in the builder stage @types/nodecatalog bumped^25->^26to match the runtime major- api-hono's tsdown
targetcorrectednode24->node26, so the bundle is no longer down-levelled below the runtime it actually ships on
Also fixes the local
test:e2e:dockerpath, which could not boot:- the Playwright image ships Node 24, below the
engines.nodefloor, so the compose entrypoint now provisions the pinned Node vianinto a cached volume and installs pnpm explicitly (Node 25+ no longer bundles corepack) playwright.config.tsinvokedpnpm --filter @libris/web run dev, a script the Vite+ task migration had removed; it now goes throughvp run -F @libris/web dev
75bb9af: Convert the user-guide screenshots from PNG to WebP.
All 29 images in
docs/guide/images/are now WebP at quality 90 (webp:method=6), and every reference inREADME.mdanddocs/guide/*.mdwas rewritten to match.This was the dominant cost in the repo: the PNGs were 7.9MB of a 12MB tracked tree, all of them unoptimized retina-scale captures at 1376x1403. WebP brings that to 1.8MB, a 77% reduction, taking the whole tracked tree from 12MB to 5.4MB — a cost every clone was paying.
Quality 90 was chosen after measuring: PSNR 41.6dB, and a 1:1 crop of a text-dense region is visually indistinguishable from the original, which matters because these are UI screenshots where readers need to make out interface text.
Application images are deliberately untouched —
apps/web/public/favicons and PWA icons must stay PNG/ICO for manifest andapple-touch-iconcompatibility, and the logos are already SVG.
1.1.1
1.1.0
Minor Changes
4b86d47: Make book language a canonical ISO 639-1 code everywhere so language filtering is reliable.
- Add a shared, dependency-free
normalizeLanguage/languageLabel/LANGUAGESmodule (@libris/api-hono/languages) used by both the API and the web app. - Predict language at ingestion: normalize the embedded EPUB
<dc:language>tag (en-GB/English/eng→en,it-IT/Italian→it). When the tag is missing or unrecognized, detect the language (tinyld) from a sample of the book's body prose (spine-ordered, skipping short front matter), falling back to the title + description. The approve, PATCH, and apply-metadata routes re-normalize on write as a safety net. - Replace the free-text language inputs with a searchable language select in the edit modal and the inbox review picker; the library filter, chips, table, and badges now display full language names while filtering by code.
- Add a
db:normalize-languagesbackfill script (dry-run by default,--applyto write) to clean up existing inconsistent values. - Add a "Browse by Language" OPDS catalog: a navigation feed listing only the languages present in the library (as full names) plus per-language acquisition feeds (
/opds/languagesand/opds/languages/{code}).
- Add a shared, dependency-free
1.0.1
Patch Changes
1442ee7: Fix books getting stranded in "inbox" and unapprovable when their EPUB has embedded metadata but the automatic Hardcover lookup returns no results. The metadata-fetch worker now promotes such books to "review" (they already hold a file-derived metadata candidate and are review-ready) instead of leaving them in "inbox", where the Approve action is permanently disabled even after a successful manual Hardcover search.
Also fix the library
PATCH /api/library/:idendpoint only re-organizing (and thus re-embedding the EPUB) when the cover changed. Editing an embedded field such as title, author, publisher, or description now re-runs organize so the on-disk EPUB and its file location stay in sync with the database instead of silently drifting.
1.0.0
Initial public release.