Skip to content

feat: personal library over XRPC with atproto service auth - #205

Merged
nperez0111 merged 10 commits into
mainfrom
feat/kosync-filename-matching
Aug 12, 2026
Merged

feat: personal library over XRPC with atproto service auth#205
nperez0111 merged 10 commits into
mainfrom
feat/kosync-filename-matching

Conversation

@nperez0111

@nperez0111 nperez0111 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

E-reader sync now works for the majority of KOReader users: filenameHash/filenameKey columns (migration 022), a three-way SAME_BOOK_FILE predicate, and matchSyncDocumentForUser, which resolves a synced document against the user's uploaded files rather than only the title/author a default-configured KOReader never sends. Every OPDS capability now has an XRPC equivalent — getPersonalBookFile/getPersonalBookCover (blob outputs), listPersonalShelves, and q/sort/storage on getPersonalLibrary — authenticated with atproto inter-service auth via ServiceJwtVerifier, so a script or e-reader can use the library instead of only a browser session, with uploadPersonalBook taking a real MIME allowlist and a required filename param in place of */* and an x-file-name header. The two drifted upload implementations collapse into one shared core that streams to disk instead of buffering, which removes a double-buffering bug in bodyLimit() and stops the epub/cbz parsers inflating every image in an archive to keep one cover, taking worst-case upload RSS from unbounded to a bounded ~630 MB cluster-wide. Adds a 2 GB per-user storage quota evaluated inside the INSERT so concurrent uploads cannot both slip past — checked against production first, where the heaviest user holds 39 MB. Also supersedes #204 (carrying its backfill-progress persistence forward with a fix: the stored value was JSON.stringifyd, which unstorage reads back as an object, so every persisted run was silently discarded) and gives OPDS covers an ETag, which production served 43 of in 48h without ever answering a single 304.

Verified: 559 tests pass (up from 475 on main, stable across repeated runs), typecheck and lint clean, production build succeeds, and the real app was booted to confirm the new routes authenticate rather than 404.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added personal library search, sorting, shelf views, storage usage, quotas, and book downloads.
    • Added streaming uploads for EPUB, MOBI/AZW/AZW3, FB2, FB2.zip, and CBZ books.
    • Added local cover retrieval, validation, SVG conversion, and conditional caching.
    • Improved KOReader synchronization with filename-based matching and progress linking.
    • Added service authentication for supported API requests.
  • Bug Fixes

    • Improved duplicate, size, unsupported-format, and quota error handling.
    • Reduced processing for large ebook archives and images.
    • Preserved sync links more reliably during concurrent updates.

Makes the whole personal library reachable over /xrpc/*, authenticated with
atproto inter-service auth so a script or e-reader can use it rather than only
a browser session. Asked for in
https://bsky.md/profile/pds.dad/post/3msrmxae2tk2g.

Binary bodies: uploadPersonalBook declares an explicit ebook MIME allowlist
instead of */* (which made @atcute/lex-cli emit no validator at all) and takes
`filename` as a required param, replacing an x-file-name header that defaulted
to "unknown". application/octet-stream is in the list deliberately — the iOS
app, mobile pickers and curl all send it; detectFormat checking magic bytes
against the extension is the real gate.

OPDS parity: new getPersonalBookFile and getPersonalBookCover (blob outputs
owning their own ETag/304), new listPersonalShelves (the root call — /opds root
was 431 of 551 OPDS hits in 48h of production traffic), and q/sort/storage on
getPersonalLibrary.

Service auth via ServiceJwtVerifier. Methods declare auth: "identity" |
"pdsWrite" and the registration wrapper derives the lxm from the schema's NSID,
so a method's route and its token binding cannot drift. pdsWrite refuses
service auth outright — it proves key control, not that we hold an OAuth grant.
isKnownAccount gates it so an arbitrary DID can't open a quota's worth of disk.

One upload implementation. processBookUpload and /library/upload had drifted in
four ways; both are now thin adapters over src/utils/uploadPersonalBook.ts.
That closed real memory problems: bodyLimit() buffered chunked bodies twice,
and the epub/cbz parsers inflated every image in an archive to keep one cover.
Worst-case upload RSS goes from unbounded to a bounded ~630 MB cluster-wide.

Storage quota (2 GB), enforced as a SUM evaluated inside the INSERT so two
concurrent uploads can't both slip past. Verified safe against production: the
heaviest user holds 39 MB.

Also fixes the KOSync progress bridge (an upload before the first sync left
sync_document.hiveId null forever), persists admin backfill progress across
restarts, and gives OPDS covers an ETag — production served 43 cover fetches in
48h and could never answer a single one with a 304.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 794d7775-f0ce-4ff4-be97-98bd87227cc1

📥 Commits

Reviewing files that changed from the base of the PR and between 516eef3 and a98527f.

📒 Files selected for processing (1)
  • src/utils/syncMatching.ts

📝 Walkthrough

Walkthrough

This PR adds shared personal-book uploads, filename-based sync matching, personal-library XRPC methods, binary retrieval, service-auth JWT verification, replay protection, KV-backed backfill progress, storage quotas, and related tests and documentation.

Changes

Personal library platform

Layer / File(s) Summary
Library contracts and storage
lexicons/*, src/env.ts, src/types.ts, src/db.ts, src/utils/personalLibrary.ts
Personal-library schemas define storage, search, sorting, shelves, binary retrieval, upload errors, filenames, covers, and quota fields. Migrations add filename identities, indexes, and normalized nullable metadata.
Filename and sync matching
src/utils/filenameMatching.ts, src/utils/bookMatching.ts, src/utils/syncMatching.ts, src/routes/sync/kosync.ts
Matching uses filename hashes, normalized filename keys, metadata normalization, bounded FTS fallback, user-scoped uploaded-file matching, and guarded link updates.
Shared upload and cover processing
src/utils/uploadPersonalBook.ts, src/utils/bookMetadata/*, src/routes/library.tsx
Uploads stream into bounded temporary files, enforce size and quota limits, reject duplicates before parsing, validate formats, prepare covers, persist files atomically, and bridge sync progress.
Personal-library XRPC surface
src/xrpc/router.ts, src/routes/opds.ts, src/app.ts, src/client/components/LibraryManager.tsx, src/pages/library.tsx
Handlers add authenticated search, sorting, shelves, storage reporting, streaming book and cover retrieval, conditional responses, uploads, deletion, and upload-error displays. OPDS covers support ETags.
Service authentication and account recognition
src/xrpc/auth.ts, src/xrpc/replay-store.ts, src/context.ts, src/auth/client.ts, src/bsky/id-resolver.ts
XRPC supports session credentials and service-auth JWTs. Verification checks audiences, method bindings, token age, issuer resolution, and OAuth-only write methods. Optional replay protection uses SQLite-backed KV storage.
Operational persistence and test support
src/utils/catalogBookService.ts, src/routes/admin.ts, src/test/env-setup.ts, bunfig.toml, AGENTS.md, vite.config.ts, tests
Backfill progress persists in KV. Stale running jobs become interrupted after restart. Test processes receive isolated database and library paths. Runtime documentation and native dependency tracing are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant XRPCRouter
  participant ServiceJwtVerifier
  participant UploadPersonalBook
  participant Database
  participant LibraryStorage

  Client->>XRPCRouter: Upload personal book with Bearer token
  XRPCRouter->>ServiceJwtVerifier: Verify audience and method binding
  ServiceJwtVerifier-->>XRPCRouter: Return service identity
  XRPCRouter->>UploadPersonalBook: Stream file for authenticated DID
  UploadPersonalBook->>Database: Validate duplicate, quota, and matching state
  UploadPersonalBook->>LibraryStorage: Move validated book and cover
  UploadPersonalBook-->>XRPCRouter: Return book view and storage usage
  XRPCRouter-->>Client: Return upload result
Loading

Poem

A rabbit checks each book by name,
Then streams it through the upload frame.
Covers turn to bounded light,
JWTs verify access rights.
Sync links wait for matching proof—
Safe files settle under one roof.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's main changes: personal library access over XRPC and atproto service authentication.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kosync-filename-matching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 27

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 553-557: Correct the `writeCapped` description in AGENTS.md to
match the implementation’s 1 MB stream highWaterMark and stated memory bound.
Replace the inaccurate ~64 KB RSS figure while preserving the explanation of
streaming to `.part` files and capping the body.
- Around line 886-901: Add an explicit language identifier to the fenced code
block containing the PDS and BookHive HTTP examples, using http or text to
satisfy markdownlint MD040 while leaving the example content unchanged.
- Line 471: Update the personal_book schema documentation to name the sizeBytes
column instead of fileSize, and correct the primary-key description to match the
composite key defined in src/db.ts so identical content uploaded by different
users is represented by separate rows.

In `@lexicons/getPersonalLibrary.json`:
- Around line 134-138: Update the description schema in getPersonalLibrary so
its declared limit matches stored catalog data: either remove maxLength or
enforce a UTF-8-aware truncation before returning descriptions, ensuring values
longer than 5000 bytes cannot violate the contract.

In `@lexicons/uploadPersonalBook.json`:
- Around line 32-44: Update the required-property declaration for the
uploadPersonalBook response schema to include both storageUsedBytes and
storageQuotaBytes alongside book, ensuring generated client types treat these
always-returned fields as mandatory.

In `@src/client/components/LibraryManager.tsx`:
- Around line 121-135: Update handleDelete so a successful deletion refreshes
the active list and storage state by invoking fetchBooks, or accurately
subtracts the deleted book’s size from storage. Ensure the quota meter reflects
the freed space immediately and no longer blocks uploads based on stale usage.

In `@src/context.ts`:
- Around line 362-372: Update the replay sweep setup around isPrimaryWorker to
require env.XRPC_SERVICE_AUTH in addition to env.XRPC_SERVICE_AUTH_REPLAY before
creating the interval, preventing sweeps when replay storage was not initialized
by the service-auth branch.

In `@src/db.ts`:
- Around line 940-967: The migration 022 backfill must use normalization logic
that cannot change with shared application helpers. Replace the calls to
filenameKey and koreaderFilenameHash in the personal_book and sync_document
backfill loops with frozen local implementations copied into the migration, or
add a subsequent migration that recomputes both columns when those helpers
change; ensure SAME_BOOK_FILE continues comparing consistently normalized
values.

In `@src/routes/library.tsx`:
- Around line 26-31: Extract the duplicated formatBytes helper from the route
and LibraryManager into a shared module, export it there, and import and reuse
that shared symbol in both consumers so quota formatting remains consistent.
- Around line 76-102: Apply Hono’s zValidator form middleware to the /upload
route and define a Zod schema for the multipart file field, replacing the manual
formData/file validation while preserving the existing error responses. Also
validate the error query parameter before it reaches LibraryPage using
zValidator query and its schema, reusing the validated value in the page flow.

In `@src/routes/sync/kosync.test.ts`:
- Around line 143-147: Update the afterEach cleanup around personalBookDir so it
resolves the configured LIBRARY_DIR and verifies the root is the expected
test-only temporary directory before invoking recursive rm; if the guard fails,
do not delete anything. Preserve cleanup of the test user directory when the
assertion passes.

In `@src/utils/bookMetadata/cbz.ts`:
- Around line 43-44: Update the cover metadata construction around extOf and
cover to call extOf(first.name) once, store its result, then normalize "jpeg" to
"jpg" before passing the extension to mimeForExt and assigning ext.

In `@src/utils/bookMetadata/epub.ts`:
- Around line 126-138: Enforce MAX_COVER_BYTES against the actual inflated data,
not only the ZIP metadata: update inflateCover in src/utils/bookMetadata/epub.ts
(lines 126-138) to reject data when data.length exceeds the cap, and apply the
same post-inflate or streaming limit to the page-1 inflation in
src/utils/bookMetadata/cbz.ts (lines 33-41).

In `@src/utils/bookMetadata/testFixtures.ts`:
- Around line 105-121: Update the files declaration in the fixture-building
function to support per-file ZIP options, then define the mimetype entry using
the proposed tuple with compression level 0 so it is stored rather than
deflated. Leave the other entries unchanged and preserve the existing cover,
padding, and zipSync behavior.

In `@src/utils/catalogBookService.ts`:
- Around line 273-301: Update getBackfillProgress so backfillProgress is
returned locally only while its status is "running"; after completion or
failure, read the KV state as authoritative. Change persistProgress to write an
immutable snapshot rather than the mutable global object, serialize progress
writes to prevent stale delayed writes from overwriting newer records, and await
the final terminal write while handling errors locally. Add a regression test
covering a worker observing newer progress after another worker starts a
subsequent backfill.

In `@src/utils/syncMatching.ts`:
- Around line 206-234: Update the query construction before the FTS loop so
author signals cannot consume all MAX_FTS_QUERIES slots: reserve at least one
slot for a candidate title query when title queries exist, while retaining the
existing author and title deduplication behavior. Ensure the slice passed to the
FTS execution loop includes that reserved title query and still respects
MAX_FTS_QUERIES.
- Around line 336-347: Guard the personal_book update in the hiveId/file
writeback block with a database-side null check, adding a where condition that
requires hiveId to still be null before setting it. Keep the existing user_book
ownership update unchanged, and anchor the change to the personal_book update in
the surrounding sync flow.

In `@src/utils/uploadPersonalBook.test.ts`:
- Around line 456-534: Add coverage in the rejection tests for the untested busy
result and byte-source cap path: saturate parseSemaphore while invoking
uploadPersonalBook and assert it returns { ok: false, reason: "busy" }, then
invoke uploadPersonalBook with source.kind "bytes" containing data over
MAX_PERSONAL_BOOK_BYTES and assert a too-large rejection with no temporary
entries or persisted record.

In `@src/utils/uploadPersonalBook.ts`:
- Around line 70-85: The deployed process count is three, so update all related
documentation to use WEB_CONCURRENCY=3 and recalculate the cluster-wide upload
memory ceiling as approximately 630 MB. In src/utils/uploadPersonalBook.ts,
correct the parseSemaphore comment and the “four independent processes”
reference near the upload flow; in AGENTS.md, make the production process-count
statement consistent with the existing WEB_CONCURRENCY=3 and ~630 MB guidance.
- Around line 429-432: Move the cover write in the upload flow before the
insertIfUnderQuota commit, or handle Bun.write failure by clearing the committed
row’s coverPath before propagating the error. Ensure the success payload’s
coverUrl reflects the final persisted cover state, including a null/absent value
when cover storage fails.
- Around line 210-224: Update insertIfUnderQuota to add ON CONFLICT (userDid,
contentHash) DO NOTHING to the INSERT, then distinguish a duplicate conflict
from a quota rejection when no row is affected. Return the established
discriminated duplicate result for the conflict, while preserving the
quota-exceeded result for rows excluded by the quota condition.

In `@src/xrpc/auth.ts`:
- Around line 44-48: Make isKnownAccount required in XrpcAuthContext and update
the service-authentication path around the serviceJwtVerifier handling to call
it unconditionally before returning authenticated access, denying authentication
when the DID is not known; remove the optional/missing-callback bypass while
preserving existing JWT verification behavior.

In `@src/xrpc/personalLibrary.test.ts`:
- Around line 419-426: Update the test around the “defaults to newest first”
case to include a non-empty q query parameter and verify that setting q does not
alter the default ordering. Make the assertion deterministic by avoiding
dependence on equal createdAt timestamps, or by asserting the documented stable
ordering with an explicit tiebreaker consistent with the router behavior.

In `@src/xrpc/replay-store.ts`:
- Around line 42-48: Update the production Dockerfile’s oven/bun image reference
from the floating 1-alpine tag to Bun 1.3.14 or newer, ensuring the replay-store
SQL flow in the shown INSERT conflict path runs against a supported SQLite
version.

In `@src/xrpc/router.ts`:
- Around line 1495-1502: Update the three sort branches in the query ordering
expression to append personal_book.id as the final
ascending/descending-consistent tiebreaker after title, authors/title, and
createdAt respectively. Preserve the existing primary sort directions and ensure
every limit/offset ordering is deterministic.
- Around line 191-194: Make isKnownAccount a required member of XrpcContext,
while leaving serviceJwtVerifier optional. In resolveXrpcAuth, remove the
truthiness guard and always await ctx.isKnownAccount(issuer) before granting
service-auth access, preserving rejection for unknown accounts.
- Around line 1734-1743: Update the catalog redirect branch guarded by
book.hiveId to return a mutable Response constructed with status 302 and the
computed Location header instead of using Response.redirect. Preserve the
existing absolute image-proxy URL, and add a regression test covering a book
with hiveId but no coverPath.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8ad6e1b4-b999-4428-8478-aa704d54969d

📥 Commits

Reviewing files that changed from the base of the PR and between 647c004 and 00a2ffb.

⛔ Files ignored due to path filters (7)
  • bun.lock is excluded by !**/*.lock
  • src/bsky/lexicon/generated/index.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/getPersonalBookCover.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/getPersonalBookFile.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/getPersonalLibrary.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/listPersonalShelves.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/uploadPersonalBook.ts is excluded by !**/generated/**
📒 Files selected for processing (52)
  • AGENTS.md
  • bunfig.toml
  • lexicons/auth.json
  • lexicons/getPersonalBookCover.json
  • lexicons/getPersonalBookFile.json
  • lexicons/getPersonalLibrary.json
  • lexicons/listPersonalShelves.json
  • lexicons/uploadPersonalBook.json
  • package.json
  • src/app.ts
  • src/auth/client.ts
  • src/auth/router.tsx
  • src/auth/session.test.ts
  • src/auth/token-refresh.test.ts
  • src/bsky/id-resolver.ts
  • src/client/components/LibraryManager.tsx
  • src/context.ts
  • src/db.ts
  • src/env.ts
  • src/pages/library.test.tsx
  • src/pages/library.tsx
  • src/routes/admin.ts
  • src/routes/library.test.ts
  • src/routes/library.tsx
  • src/routes/opds.test.ts
  • src/routes/opds.ts
  • src/routes/sync/kosync.test.ts
  • src/routes/sync/kosync.ts
  • src/test/env-setup.ts
  • src/types.ts
  • src/utils/account.ts
  • src/utils/bookMatching.ts
  • src/utils/bookMetadata/cbz.ts
  • src/utils/bookMetadata/cover.ts
  • src/utils/bookMetadata/epub.ts
  • src/utils/bookMetadata/hash.ts
  • src/utils/bookMetadata/index.ts
  • src/utils/bookMetadata/testFixtures.ts
  • src/utils/catalogBookService.test.ts
  • src/utils/catalogBookService.ts
  • src/utils/filenameMatching.test.ts
  • src/utils/filenameMatching.ts
  • src/utils/personalLibrary.ts
  • src/utils/syncMatching.test.ts
  • src/utils/syncMatching.ts
  • src/utils/uploadPersonalBook.test.ts
  • src/utils/uploadPersonalBook.ts
  • src/xrpc/auth.test.ts
  • src/xrpc/auth.ts
  • src/xrpc/personalLibrary.test.ts
  • src/xrpc/replay-store.ts
  • src/xrpc/router.ts

Comment thread AGENTS.md Outdated
Comment thread AGENTS.md Outdated
Comment thread AGENTS.md Outdated
Comment thread lexicons/getPersonalLibrary.json
Comment thread lexicons/uploadPersonalBook.json
Comment thread src/xrpc/personalLibrary.test.ts
Comment thread src/xrpc/replay-store.ts Outdated
Comment on lines +42 to +48
const result = await sql<{ inserted: number }>`
INSERT INTO ${sql.table(REPLAY_TABLE)} (id, value, created_at, updated_at)
VALUES (${`${iss}|${jti}`}, ${expiresAt}, ${now}, ${now})
ON CONFLICT(id) DO NOTHING
RETURNING 1 as inserted
`.execute(kvDb);
return result.rows.length > 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the SQLite version available to the runtime and look for replay-store tests.
bun --version
bun -e 'import { Database } from "bun:sqlite"; const d = new Database(":memory:"); console.log(d.query("select sqlite_version() as v").get());'
fd -t f 'replay' -x echo {}
rg -n 'createKvReplayStore|sweepReplayStore|ensureReplayTable' --type=ts

Repository: nperez0111/bookhive

Length of output: 258


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- replay-store.ts ---'
cat -n src/xrpc/replay-store.ts
printf '%s\n' '--- repository manifests and runtime references ---'
git ls-files | rg '(^|/)(package\.json|bun\.lockb?|bunfig\.toml|Dockerfile.*|compose.*|.*lock|README.*)$' | head -80
printf '%s\n' '--- package/runtime references ---'
rg -n 'bun:sqlite|sqlite|better-sqlite|libsql|XRPC_SERVICE_AUTH_REPLAY|replay-store|replay' \
  --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' \
  package.json bunfig.toml Dockerfile* docker-compose* src test tests .github 2>/dev/null || true
printf '%s\n' '--- relevant manifest files ---'
for f in package.json bunfig.toml; do
  if [ -f "$f" ]; then cat -n "$f"; fi
done

Repository: nperez0111/bookhive

Length of output: 38431


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- runtime image and Bun pin ---'
for f in Dockerfile .cursor/Dockerfile compose.yaml; do
  if [ -f "$f" ]; then
    printf '%s\n' "--- $f ---"
    cat -n "$f"
  fi
done
printf '%s\n' '--- Bun references and CI setup ---'
rg -n 'oven/bun|BUN_VERSION|bun-version|setup-bun|1\.3\.14|bun:sqlite' \
  --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' \
  .github Dockerfile .cursor/README* .cursor/Dockerfile package.json README.md 2>/dev/null || true
printf '%s\n' '--- replay-related test and auth setup locations ---'
rg -n -C 8 'ensureReplayTable|createKvReplayStore|XRPC_SERVICE_AUTH_REPLAY|replayStore' \
  src/xrpc/auth.test.ts src/context.ts src/env.ts src/xrpc 2>/dev/null || true

Repository: nperez0111/bookhive

Length of output: 18269


🌐 Web query:

Bun 1.3.14 bundled SQLite version bun:sqlite

💡 Result:

In Bun 1.3.14, the bundled version of SQLite is officially 3.53.0 [1][2]. However, the actual SQLite version experienced by users may differ depending on the platform [3]: * On Linux and Windows, Bun typically uses the bundled SQLite 3.53.0 amalgamation [3][4]. * On macOS, Bun historically linked against the system-provided libsqlite3.dylib, which meant that bun:sqlite would often report an older version (such as 3.43.2) regardless of the version bundled in the Bun release notes [3][5][6]. Due to these discrepancies, users on macOS who require the features or fixes present in newer SQLite versions (such as 3.53.0) may need to point bun:sqlite to a custom SQLite build using Database.setCustomSQLite(path) [6]. Note that Bun development has actively worked on addressing this by moving toward statically linking the bundled SQLite on all platforms to ensure consistency [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Bun setup workflows ---'
for f in .github/workflows/*.yml .github/workflows/*.yaml; do
  [ -f "$f" ] || continue
  if rg -q 'setup-bun|bun test|oven/bun' "$f"; then
    printf '%s\n' "--- $f ---"
    cat -n "$f"
  fi
done
printf '%s\n' '--- replay test files ---'
git ls-files '*replay*' '*auth.test.ts' | sort

Repository: nperez0111/bookhive

Length of output: 1724


Pin the production Bun image to a supported version. Bun 1.3.14 bundles SQLite 3.53.0, but Dockerfile uses the floating oven/bun:1-alpine tag. Pin it to Bun 1.3.14 or newer, or add replay-store coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/xrpc/replay-store.ts` around lines 42 - 48, Update the production
Dockerfile’s oven/bun image reference from the floating 1-alpine tag to Bun
1.3.14 or newer, ensuring the replay-store SQL flow in the shown INSERT conflict
path runs against a supported SQLite version.

Comment thread src/xrpc/router.ts Outdated
Comment thread src/xrpc/router.ts Outdated
Comment thread src/xrpc/router.ts
Review follow-ups on the personal-library XRPC work, plus two things found
while testing it against a real book.

Review fixes:
- isKnownAccount is required on XrpcAuthContext/XrpcContext and called
  unconditionally. It was optional and guarded by truthiness, so a context that
  merely forgot to wire it opened the personal library to any DID on the network.
- getPersonalBookCover builds its 302 by hand rather than with Response.redirect,
  whose headers are immutable — the downstream Cache-Control middleware mutating
  them throws a TypeError and turns the redirect into a 500.
- Every getPersonalLibrary sort ends on personal_book.id. None of the leading
  keys are unique, so LIMIT/OFFSET pages could repeat one book and drop another.
- insertIfUnderQuota gained ON CONFLICT DO NOTHING and tells a duplicate apart
  from a quota rejection, instead of raising a UNIQUE violation when the same
  file races past the duplicate check.
- A failed cover write clears coverPath instead of propagating, so a committed
  row can never point at a file that isn't there.
- The replay sweep is gated on XRPC_SERVICE_AUTH as well as ..._REPLAY; the
  svc_jti table only exists when both are on.
- syncMatching: the hiveId-null guard is enforced in the UPDATE, and author FTS
  queries can no longer starve the title query out of all four slots.
- epub/cbz re-check inflated cover bytes against MAX_COVER_BYTES; the ZIP's
  declared originalSize is attacker-controlled and covers bypass the quota.
- LibraryManager refetches after a delete so the storage meter shows freed
  space; GET /library validates ?error= against a closed set.

SVG covers (image-meta + @resvg/resvg-js):
Standard Ebooks ships every cover as an SVG holding the artwork in an <image>
element plus the title as ~40 vector paths. Bun.Image rejects SVG as an
"unrecognised format", so that whole corpus uploaded with no cover at all.
prepareCover now rasterizes via resvg and encodes to JPEG, inside the parse
semaphore so it shares the upload's native-memory bound. Both shortcuts were
tried and each loses half the cover: unwrapping the embedded raster drops the
lettering, rendering with @takumi-rs drops the artwork. @resvg/resvg-js* is in
traceDeps because a missing native binding does not crash — it silently
restores the bug.

Also fixes PNG_32, which was never a valid PNG: it built IDAT with
Bun.deflateSync (raw deflate) where the format requires zlib. Bun's decoder is
lenient, resvg's is not, so the fixture only worked by accident.

AGENTS.md, audited against the codebase: seed:db documented a script whose
entrypoint was deleted long ago, 44 lexicons not ~27, 5 worker bundles not 4,
personal_book/personal_shelf_item key columns, the @ alias (configured in Vite
but absent from tsconfig, so unusable in src/), and the unreferenced screenshot
count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/utils/bookMetadata/epub.ts (1)

126-142: 🩺 Stability & Availability | 🟡 Minor | ⚖️ Poor tradeoff

Both cover extractors allocate the full inflated entry before they reject it. Each file now re-checks the real byte length against MAX_COVER_BYTES, which closes the storage half of the earlier finding. The memory half remains: unzipSync inflates the selected entry completely before the check runs, so a crafted archive that understates originalSize still forces the full decompressed allocation. parseSemaphore bounds the concurrency, not the per-entry size.

  • src/utils/bookMetadata/epub.ts#L126-L142: replace the unzipSync call in inflateCover with a streaming inflate that aborts once the output passes MAX_COVER_BYTES.
  • src/utils/bookMetadata/cbz.ts#L40-L44: apply the same streaming inflate with an abort cap to the page-1 extraction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/bookMetadata/epub.ts` around lines 126 - 142, Both cover extractors
must enforce MAX_COVER_BYTES during decompression rather than after full
allocation. In src/utils/bookMetadata/epub.ts lines 126-142, update inflateCover
to use a streaming inflate that aborts once output exceeds MAX_COVER_BYTES; in
src/utils/bookMetadata/cbz.ts lines 40-44, apply the same capped streaming
extraction to page 1. Preserve the existing rejection and cover-validation
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/bookMetadata/cover.ts`:
- Around line 63-85: Update rasterizeSvgCover’s Resvg configuration to disable
per-instance system-font discovery by setting font.loadSystemFonts to false.
Preserve the existing rendering options, and supply any required fonts through
Resvg’s fontFiles or fontDirs configuration if the SVG rendering flow depends on
them.

In `@src/utils/syncMatching.ts`:
- Around line 352-361: The sync flow around the guarded `personal_book` update
must use the persisted link when the update affects zero rows. Inspect the
result of the update in the surrounding function, read the existing
`personal_book.hiveId` when another request won the race, and use that resolved
ID for the subsequent `user_book` ownership update and return value; retain the
requested `hiveId` only when this update establishes the link.

In `@src/utils/uploadPersonalBook.ts`:
- Around line 450-472: Wrap the post-commit rename in the upload flow around
ensureDir, rename, and the committed-row state so a rename failure compensates
by deleting the corresponding personal_book row before rethrowing the error. Use
the existing userDid and contentHash predicates, and preserve successful rename
and subsequent cover handling unchanged.

In `@src/xrpc/personalLibrary.test.ts`:
- Around line 367-379: Remove the duplicated response-parsing fragments in
src/xrpc/personalLibrary.test.ts at lines 367-379 and 450-470: delete the
repeated completed json() expression after the upload result assertion, and
remove the duplicate books type member plus the repeated searched json()
expression. Preserve the surrounding test logic and the original response
parsing.

---

Duplicate comments:
In `@src/utils/bookMetadata/epub.ts`:
- Around line 126-142: Both cover extractors must enforce MAX_COVER_BYTES during
decompression rather than after full allocation. In
src/utils/bookMetadata/epub.ts lines 126-142, update inflateCover to use a
streaming inflate that aborts once output exceeds MAX_COVER_BYTES; in
src/utils/bookMetadata/cbz.ts lines 40-44, apply the same capped streaming
extraction to page 1. Preserve the existing rejection and cover-validation
behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 309eb1ac-0f63-4c2b-ac9f-7bd35b3384f0

📥 Commits

Reviewing files that changed from the base of the PR and between 00a2ffb and f5ae4f3.

⛔ Files ignored due to path filters (3)
  • bun.lock is excluded by !**/*.lock
  • src/bsky/lexicon/generated/types/buzz/bookhive/getPersonalLibrary.ts is excluded by !**/generated/**
  • src/bsky/lexicon/generated/types/buzz/bookhive/uploadPersonalBook.ts is excluded by !**/generated/**
📒 Files selected for processing (24)
  • AGENTS.md
  • lexicons/getPersonalLibrary.json
  • lexicons/uploadPersonalBook.json
  • package.json
  • src/client/components/LibraryManager.tsx
  • src/context.ts
  • src/db.ts
  • src/routes/library.tsx
  • src/utils/bookMetadata/bookMetadata.test.ts
  • src/utils/bookMetadata/cbz.ts
  • src/utils/bookMetadata/cover.ts
  • src/utils/bookMetadata/epub.ts
  • src/utils/bookMetadata/index.ts
  • src/utils/bookMetadata/testFixtures.ts
  • src/utils/catalogBookService.ts
  • src/utils/filenameMatching.ts
  • src/utils/imageProxy.ts
  • src/utils/syncMatching.ts
  • src/utils/uploadPersonalBook.test.ts
  • src/utils/uploadPersonalBook.ts
  • src/xrpc/auth.ts
  • src/xrpc/personalLibrary.test.ts
  • src/xrpc/router.ts
  • vite.config.ts

Comment thread src/utils/bookMetadata/cover.ts
Comment thread src/utils/syncMatching.ts Outdated
Comment thread src/utils/uploadPersonalBook.ts
Comment on lines +367 to +379

it("redirects to the catalog image for a linked book with no stored cover", async () => {
// The branch that used to be built with `Response.redirect`, whose headers
// are immutable — the Cache-Control middleware setting a header on it threw
// a TypeError and turned the 302 into a 500.
const app = createApp();
const hash = (
(await (
await uploadRequest(app, makeFb2(), "x.fb2", {
contentType: "application/x-fictionbook+xml",
})
).json()) as { book: { contentHash: string } }
).book.contentHash;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicated response-parsing fragments.

The duplicated statements make src/xrpc/personalLibrary.test.ts invalid TypeScript. Line 378 repeats a completed json() expression. Line 457 repeats the books type member. Line 465 repeats another completed json() expression.

  • src/xrpc/personalLibrary.test.ts#L367-L379: remove the second response-parsing fragment after the upload result type assertion.
  • src/xrpc/personalLibrary.test.ts#L450-L470: remove the duplicate books member and the second response-parsing fragment for searched.
📍 Affects 1 file
  • src/xrpc/personalLibrary.test.ts#L367-L379 (this comment)
  • src/xrpc/personalLibrary.test.ts#L450-L470
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/xrpc/personalLibrary.test.ts` around lines 367 - 379, Remove the
duplicated response-parsing fragments in src/xrpc/personalLibrary.test.ts at
lines 367-379 and 450-470: delete the repeated completed json() expression after
the upload result assertion, and remove the duplicate books type member plus the
repeated searched json() expression. Preserve the surrounding test logic and the
original response parsing.

nperez0111 and others added 2 commits August 11, 2026 23:10
Service auth on /xrpc/* no longer requires that a DID have used BookHive
before — any valid inter-service token is accepted. Signup is open, so
the gate bought little; the per-user storage quota is the real backstop
on what a caller can consume, and pdsWrite methods still refuse service
auth.

Removes markAccount/isKnownAccount, the account: KV mount, and the
isKnownAccount field from XrpcAuthContext/XrpcContext/AppContext. Deletes
src/utils/account.ts. Updates the auth tests (a stranger's valid token
now 200s) and AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- cover.ts: disable resvg per-instance system-font scanning
  (loadSystemFonts: false); these SVG covers carry outlined text, so no
  fonts are needed and the scan was pure per-upload cost.
- syncMatching.ts: when the guarded personal_book link update loses a
  race (0 rows), adopt the persisted hiveId for the ownership update and
  return value instead of writing our own guess — a wrong link is worse
  than no link.
- uploadPersonalBook.ts: roll back the committed personal_book row if the
  post-commit rename fails, so a failed move can't leave a dead download
  and phantom quota usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/utils/uploadPersonalBook.ts (1)

450-465: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include ensureDir in the rollback scope.

If ensureDir fails after Line 418 commits the row, execution bypasses the catch block. The database then retains a personal_book row with no file, and a retry returns duplicate.

Move ensureDir inside the try block so every post-commit filesystem failure deletes the committed row before rethrowing.

Proposed fix
-    await ensureDir(personalBookDir(userDid, contentHash));
     try {
+      await ensureDir(personalBookDir(userDid, contentHash));
       await rename(tmp, filePath);
     } catch (err) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/uploadPersonalBook.ts` around lines 450 - 465, Move the ensureDir
call into the try block that currently wraps rename in the post-commit flow.
Keep the existing personal_book deletion and rethrow in the catch so failures
from either ensureDir or rename roll back the committed row before cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/syncMatching.ts`:
- Around line 367-374: Update the fallback logic after the `personal_book`
update in `syncMatching` so `hiveId` is always synchronized with the persisted
value: assign the stored `current.hiveId` when usable, otherwise clear it to
`null` when absent or equal to `NO_HIVE_MATCH`, preventing later ownership logic
from using the stale local guess.

---

Duplicate comments:
In `@src/utils/uploadPersonalBook.ts`:
- Around line 450-465: Move the ensureDir call into the try block that currently
wraps rename in the post-commit flow. Keep the existing personal_book deletion
and rethrow in the catch so failures from either ensureDir or rename roll back
the committed row before cleanup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7ae52cb2-ac99-434a-adc5-6909296c2aca

📥 Commits

Reviewing files that changed from the base of the PR and between 9f0f355 and 516eef3.

📒 Files selected for processing (3)
  • src/utils/bookMetadata/cover.ts
  • src/utils/syncMatching.ts
  • src/utils/uploadPersonalBook.ts

Comment thread src/utils/syncMatching.ts
nperez0111 and others added 6 commits August 12, 2026 08:38
When the guarded personal_book link update loses the race, clear hiveId
to null if the persisted value is absent or NO_HIVE_MATCH instead of
keeping the stale local guess. This stops the ownership update and return
value from establishing a wrong link when the winner dismissed the match
or the row was deleted concurrently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Service auth is now always on (no XRPC_SERVICE_AUTH kill switch), the
max-age window is a constant (SERVICE_AUTH_MAX_AGE_SECONDS = 3600) rather
than an env var, and replay protection is removed entirely — a token is
already scoped to one lxm and one audience, so within its short window
"authed is authed". Deletes src/xrpc/replay-store.ts and its svc_jti
table plumbing. XRPC_SERVICE_AUTH_AUDIENCES stays configurable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parseBook (fflate unzipSync) and prepareCover (synchronous resvg raster)
are the only whole-file, CPU-bound steps of an upload; running them inline
stalled the request process's event loop. They now run in a throwaway Bun
Worker spawned per upload (parse-client.ts -> parse-worker.ts), reading
the file from the temp path so nothing large crosses the thread boundary.

The parse semaphore still bounds concurrency — now the number of live
workers — and sheds excess as busy (503). The worker is terminated on
every path (reply, error, or 60s deadline). Registered as the 6th
standalone worker bundle; resvg's native binding is emitted alongside it
by bun build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS client side of the storage-quota feature: a StorageMeter component,
storage usage read off getPersonalLibrary results, and quota-aware upload
handling in the library/sync screens. Authored in parallel; committed
here as part of the branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nperez0111
nperez0111 merged commit 28be8eb into main Aug 12, 2026
1 of 2 checks passed
nperez0111 added a commit that referenced this pull request Aug 12, 2026
…users

Three fixes for the session/auth failure paths surfaced after the re-signin
deploy (#205):

- Tolerate a corrupt/tampered/rotated-secret `sid` cookie. iron-session throws
  `Wrong mac prefix` when the HMAC doesn't verify; that throw was uncaught in
  both `getSessionAgent` and the `getProfile` fast path, so a modified cookie
  hard-500'd every route for that browser (and a COOKIE_SECRET rotation would
  do it to every existing user). `readIronSession` now catches, treats it as no
  session, and expires the bad cookie so the browser recovers on its own.

- Give the 401 "Invalid Session" error page a way back in. `ErrorPage` only
  offered "Go back home" and "Contact support" — it told users to log in with
  no login link. It now shows a primary "Sign in" button (→ /login) whenever
  the status is 401, covering every "Invalid Session" call site without
  touching each route.

- Add a regression test for the tampered-cookie path.

Note: the scope change in #205 does not invalidate existing OAuth grants —
sessions are only destroyed on genuinely terminal errors — so existing
credentials keep working; these fixes only harden the invalid/failed paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant