Skip to content

fix(audit): coverage pass — wire every unlogged sensitive surface#145

Merged
bndct-devops merged 15 commits into
mainfrom
feat/audit-coverage
Jul 19, 2026
Merged

fix(audit): coverage pass — wire every unlogged sensitive surface#145
bndct-devops merged 15 commits into
mainfrom
feat/audit-coverage

Conversation

@bndct-devops

Copy link
Copy Markdown
Owner

Stack addendum #2 (merges last, after #144). Prompted by review: "is new stuff still being audited?" — answer was no, and not just the overnight batch.

The survey

Mutating endpoints vs audit calls per module showed whole features shipped unlogged: tome_sync (plugin keys), series (meta/arcs), notifications (channels), hardcover (account linking), book_types, opds_pins (credentials!), goals, annotations, stats, plus everything from the overnight run.

What now logs

Surface Actions
Instance backup backup.downloaded, restore_staged, restore_cancelled, restore_applied (marker file → logged into the restored DB at next startup, since the apply runs pre-engine)
Credentials opds_pin.created/revoked, plugin_key.created/revoked (prefix only), notify_channel.created/deleted (kind+url, never the token), hardcover.linked/unlinked
Curation book_type.created/updated/deleted, series.meta_updated, series.arc_created/arc_deleted/arcs_bulk_set, books.cover_changed
Bulk / destructive reading_import.applied (counts), books.bulk_downloaded (parity with the already-audited single download), session.deleted, annotation.deleted, books.position_restored

Deliberately NOT logged (now documented as the convention in services/audit.py)

Reads, device sync telemetry (position/session/annotation heartbeats — would flood), and per-user micro-writes (status toggles, goals, dashboard layouts). The convention doubles as the checklist rule going forward: sensitive endpoint ⇒ audit call in the same PR.

Verification

6 new tests (PIN + plugin-key lifecycles incl. secret-never-in-log assertions, book-type CRUD, channel create/delete without token leakage, import-apply details, invalid-restore-not-logged) — and the full suite: 974 passed on the stack tip.

bndct-devops and others added 15 commits July 18, 2026 23:46
- The tooltip resolves the day under the cursor (date + that day's minutes,
  or "no reading" inside a gap) below the book totals — the per-day data was
  only legible as tick opacity before.
- Phones (<480px container): the rail narrows 200->96px so the ribbon keeps
  the viewport; coarse-pointer taps inspect first (tooltip), navigate on the
  second tap on the same bar. Scroll or empty-space tap dismisses.

Book-page activity sparkline (planned as part of this PR) was investigated
and dropped: the per-book stats hero already charts per-day activity for
both live sessions and imported page-stats (sessions are synthesized from
page-stats), so the strip would duplicate an adjacent chart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /api/meta/whats-new parses CHANGELOG.md for the running version's
section (Unreleased fallback for dev builds); the frontend shows a one-time
dismissible panel when the server version differs from the last one this
browser saw. Fresh browsers baseline silently — first login never opens
with a modal. Panel portals to <body>: the header that mounts it has
backdrop-blur, which would otherwise contain the fixed overlay.

GET /api/meta/update-check (admin-only) compares against the latest GitHub
release, cached in memory 24h (1h after failures), gated by
TOME_UPDATE_CHECK (default true). Settings -> About shows a quiet
"vX.Y.Z available" link only when something is newer.

CHANGELOG.md is now copied into the Docker image for the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upload dialog: files are sha256-hashed in the browser (sequential, 512MB
cap) and checked via the existing POST /books/check-hashes before upload;
exact duplicates show "already in your library" with a link and are
skipped. The server already deduped silently — this saves the transfer
and makes it visible. Hash-check failures degrade to the old behavior.

Admin -> Covers (GET /admin/covers/audit): flags missing, unreadable, and
low-res covers (PIL header reads only). The floor is 300px — the standard
Google Books cover is 329px and fine in practice; the audit exists for the
98-128px thumbnails. Missing covers offer one-click auto-fix via the
existing cover-candidates + set-cover endpoints (nothing to downgrade);
low-res rows deep-link to the book's cover picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Global palette mounted from the shared header: full-text book search via
GET /books?q= (debounced, covers in rows), series/authors ranked from the
facets list client-side (startsWith > includes > shorter), and plain
navigation actions (admin sees Admin). Arrow keys + Enter, Esc closes,
portaled past the header's backdrop-blur containing block. Registered in
the "?" shortcuts help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New position_history table (additive, created by the startup create_all
like every other table): upsert_position — the single choke point all
position writers go through — appends an entry whenever the position moves
meaningfully (>=0.2% or locator change; the idle heartbeat can't spam it),
pruned to the newest 40 per user+book.

GET /books/{id}/position-history lists the log + live position;
POST .../{hid}/restore sets the live position back. Restore is an explicit
override of the sticky-completion rule: recovering from a false 100% also
un-finishes the book (status/finished_at), or the position would come back
while the book stayed "read". The restore itself is logged, so it can be
undone the same way. Devices converge on their next pull.

UI: history button on the book page's Reading Stats header opens the log
with per-entry Restore; refreshes stats + status pill after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /books/{id}/reader-pacing returns the chapter fraction boundaries,
book word count, and the user's true WPM (same rule as the stats tile:
finished word-counted books with >=5min reconciled read-time; null without
history). The reader computes words-left from the current relocate
fraction against the chapter's end_fraction — no pagination involved —
and shows "~N min left" in the footer beside the chapter name (250wpm
default, tooltip says which pace is in use). Recomputes when pacing
arrives, since the initial relocate races the fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-user notification_channels table (additive) + fanout hooked at the ORM
layer: an after_insert listener collects fresh Notification rows on the
session, after_commit hands them to a daemon thread that loads the owners'
enabled channels and POSTs each (5s timeout, best-effort, no retries).
Zero changes at the six Notification creation sites; a rolled-back
transaction never sends.

Settings -> Notifications: add/pause/delete channels per kind (ntfy topic
URL + optional bearer token; Gotify base URL + app token; bare webhook),
with a synchronous test button that surfaces delivery errors. Tokens are
never echoed back (has_token only). TOME_OUTBOUND_NOTIFY=false is the
operator kill-switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stateless preview + apply: POST /import/reading-csv detects the dialect
(header sets), parses read/currently-reading rows (Goodreads ="ISBN"
unwrapping, both date formats, half-star StoryGraph ratings), matches
against the caller's visible library (ISBN -> exact normalized
title+author-overlap -> fuzzy title >=0.88), and annotates exactly what
applying would fill. POST /import/reading-csv/apply is fill-gaps-only:
status only onto unread, rating/review/finished_at only when absent —
an import can never clobber live sync state or curation (same philosophy
as the TomeSync sweep). To-read shelves are skipped by design.

Settings -> Import reading history: upload, per-row preview with match
provenance and no-op dimming, checkbox selection, applied-counts summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backup: GET /admin/backup/download streams a tarball with a consistent
SQLite snapshot (sqlite3 backup API — never a raw copy of a live WAL db),
the cover cache, and a manifest. Library files stay on disk by design.

Restore is two-phase: the upload is validated (tar shape, manifest,
integrity_check, table counts) and STAGED; the swap happens at the next
server start inside create_db_engine() — before any engine or connection
exists — with the current DB kept as tome.db.pre-restore-<ts>. A corrupt
staging file is parked as .failed and startup proceeds on the current DB.
Typing RESTORE is required to arm it; staging can be cancelled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The backend round-trip already existed end to end (PUT /annotations/{id}
supports device-synced highlights with LWW mtime bumping; DELETE writes
tombstones; the plugin's _applyForeign applies newer note edits) — but the
web only exposed delete. The Highlights page and the book page's
Highlights & Notes section now edit/add notes inline; saves land on
devices at their next sync like any cross-device edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The series browser gains a Shelves entry: GET /tome-sync/shelves lists the
user's shelves with resolved counts; GET /tome-sync/shelf-books drills into
the shared _bookListMenu shape (mixed list — downloads file per-book, like
author/search lists). Shelf params resolve server-side through the
device-supported subset of the dashboard filters (q/series/no_series/
author/tag/format/language/library_id/reading_status/min_rating); the
expressions mirror books.list_books and a drift test compares results
against /books for identical params. Unsupported keys are reported, not
fatal. Foreign shelves 404.

Build 36 / semver 1.10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iew, guarantees, About polish

- Header search shows a ⌘K kbd hint when empty (nothing else advertised
  the palette existed).
- AppShell pages (Stats/Highlights/...) get a live search-preview dropdown:
  top matches with covers as you type, click-through to the book, Enter
  still opens the full grid. These pages previously gave no feedback until
  Enter — reported as "search does not preview anymore".
- What's-New: the [Unreleased] fallback is now opt-in and NOT used by the
  endpoint — a release cut without its changelog section shows nothing,
  guaranteed never unreleased content.
- Settings -> About is a proper card: section header, version badge, accent
  "Update to vX" button, GitHub button, update helper text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e spans

The panel was a wall of uniform gray text. Now: accent-washed header with
icon tile + display-font title, one card per entry with a kind-colored
icon chip (Added/Changed/Fixed/Removed), bold lead titles — including a
"Thing: detail" split fallback for older changelog entries with no bold
lead — and inline `code` spans rendered as code instead of raw backticks.
shrink-0 on header/footer so the max-height column can't compress them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Survey found whole modules shipped with zero audit calls while only legacy
surfaces logged. Added entries for: instance backup (download, restore
staged/cancelled, and restore APPLIED — via a marker file consumed at the
next startup, since the apply runs before the engine exists), OPDS PIN and
plugin API-key lifecycles, book-type CRUD, series meta/arc curation,
Hardcover link/unlink, notification channels (kind+url, never the token),
reading-import applies (counts), cover changes, bulk downloads (parity
with the audited single download), session deletes, highlight deletes,
and position restores.

Deliberately still unlogged, now documented as the convention in
services/audit.py: reads, device sync telemetry, and per-user micro-writes
(status toggles, goals, dashboards) — flood without forensic value. New
rule: any admin-gated / destructive / security-relevant / bulk-mutating
endpoint gets its audit() call in the same PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 132 fix made the wrapper full-width on max-sm, but RangeControl's own
root carried its own ml-auto, so the pills still floated right inside the
full-width row — caught on a real phone. max-sm:ml-0 on the control itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bndct-devops
bndct-devops changed the base branch from fix/stack-review-feedback to main July 19, 2026 10:10
@bndct-devops
bndct-devops merged commit 0e41e36 into main Jul 19, 2026
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