Tightens three tests whose names overstated what they checked:
- `login_succeeds_and_rotates_session` now asserts the login cookie
differs from the registration cookie, and that the registration
cookie is still valid after login (the documented contract).
- `storage::local::rejects_path_traversal` exercises three extra
rejection paths the existing implementation already handled but the
tests didn't probe: `a/./b`, the single-segment `.`, and the empty
segment `a//b`.
- `create_and_use_bot_token` asserts that `token_hash` is *absent*
from the response (`get(...).is_none()`), not just `is_null()`,
which would have accepted an explicit `"token_hash": null` payload
too.
Adds four coverage cases that the audit flagged as missing:
- `me_rejects_expired_session` — hand-craft a session row with
`expires_at = now() - 1h`, hit `/auth/me` with the matching cookie,
expect 401 + `unauthenticated`. Proves the extractor's
`expires_at > now()` filter is wired.
- `concurrent_manga_bookmarks_serialised_by_unique_index` — spawn two
POSTs in parallel for the same `(user, manga, chapter=null)`,
assert one wins (201) and one collides (409) via the partial unique
index from migration 0004.
- `bookmark_create_accepts_bearer_token` — mint a bot token and POST
/bookmarks with `Authorization: Bearer`, asserting `CurrentUser`
resolves identically to the cookie path on a write endpoint (not
just `/auth/me`).
- Three new unit tests on `app::cors_layer` covering the allowlist
(origin reflected, credentials true), a foreign origin (no
allow-origin header emitted), and the same-origin default (empty
allowlist emits no CORS headers at all).
`cors_layer` is `pub(crate)` now so the tests in `app::tests` can
reach it; the function itself is unchanged.
No version bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related correctness fixes from the audit:
- Username uniqueness was case-sensitive (`username text UNIQUE`), so
"Alice" and "alice" could both register and then race on login.
Migration 0006 adds a unique index on `lower(username)`; the
existing constraint is kept (overlapping but cheap) to avoid a
destructive migration on any deployments that may already exist.
`repo::user::find_by_username` now matches on `lower(username) =
lower($1)` so login is case-insensitive against the same index.
Test: registering "alice" then "Alice" returns 409 conflict; login
with "ALICE" succeeds against the existing user.
- `POST /api/v1/bookmarks` silently accepted `page: 0` and `page: -1`
even though both are nonsense for a 1-indexed page number. Reject
with 422 `validation_failed` and `details.page` populated, matching
the pattern used for missing-metadata / empty-title elsewhere. Test
covers both 0 and -1.
Lockstep version bump to 0.9.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a storage failure mid-chapter-upload left a partial chapter
row pointing at a `page_count` that didn't match what was on disk, plus
any successfully-inserted page rows. Same shape for a manga create
where the cover put or cover_image_path UPDATE failed after the manga
row was already inserted.
Fix at the DB layer: open `pool.begin()` at the start of the create,
do all DB writes against `&mut *tx`, commit only after the full
sequence succeeds. If anything before commit fails, the transaction is
rolled back on drop and the DB stays consistent. Bytes already written
to storage on a rolled-back transaction become orphans on disk; a
future reaper can sweep them, and we prioritise DB consistency over
storage tidiness in this branch.
- repo::manga::create / set_cover_image_path: signature changed to
`impl PgExecutor<'_>` so handlers can pass either `&PgPool` or
`&mut *tx`. set_cover_image_path is new — replaces the inline
`UPDATE` in the manga upload handler so the call site stays
consistent.
- repo::chapter::create / set_page_count: same shape.
- repo::page::create: same.
- api::mangas::create and api::chapters::create both open a
transaction around their DB writes; storage puts happen inside the
transaction window (since they must precede the page-row insert), so
a failed put aborts before commit.
New integration test (api_uploads::chapter_upload_rolls_back_when_
storage_fails_mid_loop) uses a `FailingStorage` helper that errors on
the N-th `put`. With N=1 (page 2 fails), the handler returns 500 and
the chapter + page tables stay empty.
`harness_with_failing_storage` is exposed alongside the existing
`harness` so future tests can reuse it for other fault-injection
cases.
Lockstep version bump to 0.9.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The reader route is keyed on chapter number (URL `/manga/{id}/chapter/{n}`,
loaded via `Number(params.n)`), but the bookmarks list was building
hrefs from `chapter_id` (a UUID). Following any chapter bookmark
produced a NaN load on the reader page.
Fix at the API layer so every consumer of /me/bookmarks gets the
information without a follow-up round-trip per bookmark.
- domain::BookmarkSummary: new type, `Bookmark` plus
`chapter_number: Option<i32>`. Populated by a LEFT JOIN on chapters
so manga-level bookmarks come back with `chapter_number = null` and
chapter-level ones get the value. `Bookmark` itself stays minimal
for POST / DELETE responses.
- repo::bookmark::list_for_user returns Vec<BookmarkSummary>.
- api::bookmarks::list_me returns PagedResponse<BookmarkSummary>.
- Frontend `Bookmark` type carries an optional `chapter_number`.
- /bookmarks page builds `/manga/{manga_id}/chapter/{chapter_number}`
for chapter bookmarks, falling back to the manga overview if the
chapter has been deleted out from under the bookmark (chapter_id is
ON DELETE SET NULL, so this is a real edge case).
New test asserts both branches of the JOIN: a chapter-level bookmark
comes back with the right chapter_number and page, a manga-level one
has a null chapter_number.
Lockstep version bump to 0.9.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- Migration 0005_search.sql enables pg_trgm and adds GIN indexes
(gin_trgm_ops) on mangas.title and on mangas.author (partial, WHERE
author IS NOT NULL).
- repo::manga::list keeps the existing substring (ILIKE) clause and
adds the `%` operator on title + author so the search tolerates typos
('narto' → 'Naruto'). Both branches share the trgm index. A second
count(*) query (same WHERE clause, indexed) yields the total without
scanning twice in any meaningful sense.
- New ListSort enum (Recent / Title) interpolated into ORDER BY from a
hard-coded match — never from request input, so the format!() is not
a SQL-injection seam. Default stays Recent (created_at DESC).
- api::mangas accepts `?sort=recent|title` (snake_case) via serde and
returns `page.total` as a number instead of null.
- api::pagination::PagedResponse gains a `with_total` constructor.
Backend coverage in tests/api_mangas.rs (4 new cases plus the existing
list_is_empty_initially updated to assert total: 0):
- list_returns_total_count_independent_of_pagination — limit=2 with 3
rows returns 2 items and total=3.
- search_via_trigram_tolerates_typos — `?search=narto` finds Naruto.
- list_sort_title_orders_alphabetically — three out-of-order inserts
come back A→Z.
- search_reflects_filtered_total — search narrows total to 1.
Frontend:
- lib/api/mangas.ts gains a `MangaSort` type and threads `sort` through
listMangas's query-string builder.
- Home page renders a "Sort" select (Recent / Title A→Z) that re-runs
the list query, and shows "Showing N of M" when total is present.
Lockstep version bump to 0.8.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- Migration 0004_bookmarks_unique.sql adds a partial unique index on
(user_id, manga_id) WHERE chapter_id IS NULL. The 0001 UNIQUE
constraint over (user_id, manga_id, chapter_id) doesn't block dupes
when chapter_id is NULL under Postgres's default NULLS DISTINCT, so a
user could otherwise bookmark the same manga twice at the manga
level. Chapter-level dupes are still caught by the 0001 constraint.
- repo::bookmark with create / list_for_user / find_owner / delete.
create catches the 23505 unique violation and surfaces it as
AppError::Conflict so handlers return a clean 409.
- POST /api/v1/bookmarks { manga_id, chapter_id?, page? } — CurrentUser
required. Pre-validates the manga exists (404 if not) and, when
chapter_id is supplied, that the chapter belongs to that manga (also
404), so FK violations can't bubble up as 500s.
- DELETE /api/v1/bookmarks/{id} — owner-only. 404 if unknown, 403 if it
exists for another user, 204 on success. Idempotent: deleting an
already-deleted bookmark is 404, not 500.
- GET /api/v1/me/bookmarks — paged envelope, sorted by created_at DESC,
scoped to the current user so the URL itself can't be used to peek at
someone else's bookmarks.
Integration coverage in tests/api_bookmarks.rs (9 cases): create+list
returns only own; duplicate manga-level bookmark → 409; unknown manga
→ 404; unauthenticated POST → 401; user A cannot delete user B's
bookmark (403); unknown delete → 404; double-delete → 404, not 500;
/me/bookmarks requires auth; paged envelope shape on empty list.
Frontend:
- lib/api/bookmarks.ts with createBookmark / deleteBookmark /
listMyBookmarks. listMyBookmarksOrEmpty wraps the 401 case so pages
can render anonymously without try/catch boilerplate.
- /manga/[id] overview: pre-loads the user's bookmark list in its load
function and renders either:
- "★ Bookmarked" / "☆ Bookmark" toggle with aria-pressed when authed;
click POSTs or DELETEs and mutates a local working copy of the
bookmark list (optimistic UI without re-fetching);
- or a "Sign in to bookmark" link for anonymous users.
- /bookmarks page lists the current user's bookmarks (chapter-level
bookmarks link into the reader, manga-level back to the overview).
Anonymous users see a sign-in prompt instead of a 401 page.
E2E in e2e/bookmarks.spec.ts (3 cases): authed toggle round-trip
(bookmark, see in /bookmarks list, unbookmark); anonymous user gets the
sign-in CTA on the overview; anonymous /bookmarks shows the sign-in
prompt. Existing reader.spec.ts updated for the new
bookmark-signin/toggle test IDs.
Lockstep version bump to 0.7.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- Migration 0003_pages.sql adds a `pages` table (id, chapter_id,
page_number, storage_key, content_type) with a unique (chapter_id,
page_number). New table because chapter pages can have different MIME
types per page; reconstructing keys from a single template would
break the moment a chapter mixes png and jpg pages.
- `domain::Page` + `repo::page` (create + list_for_chapter).
- The chapter upload handler now inserts one page row per part as it
writes the bytes to storage.
- GET /api/v1/mangas/{id}/chapters/{n}/pages returns `{pages: [...]}`
with the storage_key clients need to construct image URLs. 404 if
the manga or chapter doesn't exist; reads are public.
Storage trait grows `get_stream(&str) -> StreamingFile` returning a
`Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>` + size. The
local backend implements via `tokio::fs::File` + `tokio_util::io::
ReaderStream` with a 64 KiB chunk size. GET /api/v1/files/*key now
streams via `axum::body::Body::from_stream` instead of buffering — the
test asserts a 200 KiB file emits >1 frame end-to-end through the
router.
Frontend:
- lib/api/client.ts gains `fileUrl(key)` so components don't
reconstruct the `/api/v1/files/...` path manually.
- lib/api/chapters.ts gains `ChapterPage` type + `getChapterPages` (the
type is named ChapterPage to avoid colliding with `Page` from
client.ts, which is the pagination envelope).
- /manga/[id]/+page.svelte: overview with cover, title, author,
description, chapter list, and a disabled bookmark control (real
bookmarking lands in feat/bookmarks). Responsive at 640 px.
- /manga/[id]/chapter/[n]/+page.svelte: paginated reader. Current page
loads eagerly; next page is preloaded in a hidden img so navigation
feels instant. Keyboard handler maps ArrowRight/j/Space → next,
ArrowLeft/k → prev, Home/End → first/last; skips when the user is
typing in an input. Focus ring on the prev/next buttons.
- SSR is disabled on both routes via `export const ssr = false` so the
client-only fetch flow doesn't need to be replicated server-side; the
routes are interactive features, not SEO surfaces.
- E2E (e2e/reader.spec.ts): overview shows the title/cover/chapter
list; reader pages through three pages via ArrowRight, j, k, and
ArrowLeft, and the preload img holds the page-2 src on initial load.
Lockstep version bump to 0.6.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/v1/mangas and POST /api/v1/mangas/{id}/chapters now accept
multipart/form-data, gated by CurrentUser:
- /mangas: required `metadata` part (NewManga JSON) + optional `cover`
image part.
- /mangas/{id}/chapters: required `metadata` (NewChapter JSON) + one or
more `page` parts ordered by arrival. Returns 404 if the parent manga
doesn't exist, 409 on duplicate (manga_id, number).
MIME is sniffed via the `infer` crate (magic bytes), not the
client-supplied filename or Content-Type. Whitelist:
jpeg / png / webp / gif / avif. Anything else → 415
unsupported_media_type. The stored key's extension is derived from the
sniffed type so a "page1.png" that's actually a JPEG lands as `.jpg`.
Size cap is two-layer:
- Request body cap (config.max_request_bytes, default 200 MiB) enforced
by axum's DefaultBodyLimit before the handler sees the request.
- Per-image-part cap (config.max_file_bytes, default 20 MiB) enforced
after reading the part, so a single oversized image can't pass even
if the total request fits.
Storage keys follow the layout documented in CLAUDE.md:
- mangas/{manga_id}/cover.{ext}
- mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext} (1-indexed).
AppError grows PayloadTooLarge/UnsupportedMediaType/ValidationFailed
(413 / 415 / 422). ValidationFailed carries a `details` JSON object the
client can use to highlight bad fields (e.g. {"title":"required"}).
Top-level matching in code() stays exhaustive.
Backend coverage in tests/api_uploads.rs (10 cases):
- create_manga_with_cover_stores_image — file is reachable via
/api/v1/files/{key} with the right Content-Type.
- create_manga_without_cover_leaves_path_null.
- create_manga_rejects_non_image_cover_with_415 — PDF claimed as png.
- create_manga_rejects_oversized_cover_with_413.
- create_chapter_with_pages_stores_each — extension derived from
sniffed MIME, files reachable in arrival order.
- create_chapter_rejects_when_no_pages_with_422 — details.page set.
- create_chapter_rejects_renamed_non_image_page → 415.
- create_chapter_returns_409_on_duplicate_number.
- create_chapter_requires_authentication → 401.
- create_chapter_under_unknown_manga_is_404.
Existing tests/api_mangas.rs is migrated to multipart; the create
response is now 201 Created. tests/common::MultipartBuilder builds the
body by hand so the test crate stays free of HTTP-client deps.
Frontend lib/api/mangas.ts: createManga now sends FormData (metadata +
optional cover Blob). Browser fills in the boundary header automatically.
Vitest asserts the FormData structure via FileReader (jsdom doesn't
implement Blob.text()).
E2E tests wait for the post-hydration nav-login link before
interacting with the login form, fixing a flake where pre-hydration
clicks would submit via the browser default and bypass our handler.
Lockstep version bump to 0.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both endpoints are public reads — anyone browsing can see a manga's
table of contents and chapter metadata. Uploads land in feat/uploads.
- GET /api/v1/mangas/{id}/chapters returns the paged envelope
({items, page}) ordered by chapter number ASC. Surfaces 404 if the
parent manga doesn't exist so an empty result can't be mistaken for
"no chapters yet" on a real manga.
- GET /api/v1/mangas/{id}/chapters/{number} returns a single chapter,
404 if either manga or chapter is missing.
repo::chapter exposes list_for_manga, find_by_manga_and_number, and
create. create translates the (manga_id, number) unique violation into
AppError::Conflict so the upload handler can later return a clean 409.
Frontend lib/api/chapters.ts mirrors the shape with listChapters and
getChapter; Vitest asserts the URL shape, paged response handling, and
404 envelope propagation.
Lockstep version bump to 0.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the full auth flow. Reads stay public; writes (currently only POST
/api/v1/mangas) require a CurrentUser. Both browsers and bot scripts hit
the same endpoints — they just present credentials differently.
Migration 0002_auth.sql introduces users.password_hash, a sessions
table, and an api_tokens table. Sessions and api_tokens store only
sha256(raw_token) — the raw value lives in the cookie or the
Authorization header.
New endpoints under /api/v1/auth/:
- POST /register — argon2id hash, creates a session, sets cookie.
- POST /login — verifies, rotates to a fresh session (old ones expire
naturally so other devices stay signed in).
- POST /logout — deletes the server-side session row + clears the
cookie via Max-Age=0.
- GET /me — current user via the new CurrentUser extractor.
- POST /tokens — issue a bot bearer token; raw value returned exactly
once at creation.
- DELETE /tokens/{id} — owner-only: 404 if unknown, 403 if it exists
but belongs to another user, 204 on success.
The CurrentUser axum extractor resolves cookie first, then
Authorization: Bearer; failure → AppError::Unauthenticated (401). New
AppError variants Unauthenticated/Forbidden/Conflict carry the matching
envelope codes; the top-level match in `code()` stays exhaustive.
Backend integration coverage in tests/api_auth.rs: register sets a
HttpOnly SameSite=Lax cookie and never leaks password_hash; duplicate
username → 409; weak password → 400; login rotates the cookie; wrong
password / unknown user → 401; /me with vs without cookie; logout
invalidates the cookie; bot-token roundtrip via Bearer; user A cannot
delete user B's token (403); unknown delete → 404.
Frontend:
- lib/api/auth.ts — typed wrappers; me() returns null on 401.
- lib/session.svelte.ts — per-tab user state with a seq counter to
guard against an in-flight /me clobbering a fresh setUser.
- lib/api/client.ts — request<T> returns undefined for 204.
- routes/login + routes/register — forms with action="javascript:void(0)"
so the no-JS path is a no-op (avoids the hydration-race where a
pre-attach click would submit via the browser default).
- routes/+layout.svelte — session-aware nav: spinner → user + Logout,
or Login / Register.
- e2e/auth-flow.spec.ts — login flips the layout, logout flips back;
bad credentials surface the API error message.
Config grows AuthConfig (cookie_secure, cookie_domain, session_ttl_days)
and CORS_ALLOWED_ORIGINS. CORS middleware is mounted in app::build and
stays a no-op (same-origin) until origins are listed.
Lockstep version bump to 0.3.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every handler from /api/* to /api/v1/*. /api/* is now reserved for
future versioning.
Standardise the error response shape across the API as
{"error": {"code": "snake_case", "message": "..."}}. AppError gains a
`code()` whose top-level variants are matched exhaustively without a
wildcard — new variants are a compile error until coded. 500-class
responses always emit the fixed "internal error" string and log the
real cause via tracing only.
Lock in the list pagination envelope as {"items": [...], "page": {
"limit", "offset", "total"}} and apply it to GET /api/v1/mangas. `total`
serialises as null until feat/list-search-polish lands an indexed count.
The frontend client parses the envelope into ApiError.code with an
http_error fallback for non-JSON bodies. listMangas now returns the
paged shape; the root route consumes .items. New client.test.ts covers
envelope parsing and the fallback paths.
Lockstep version bump to 0.2.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set up Mangalord with a Rust/axum backend, SvelteKit frontend, Postgres,
and Docker Compose deployment. Establishes the architecture and TDD
patterns the project will extend:
- Hexagonal-ish backend layering (domain / repo / storage / api) with
a pluggable Storage trait (LocalStorage today, S3 as a future impl).
- Initial migration: users, mangas, chapters, bookmarks.
- Vertical slice for mangas (list, search, create, get) with
#[sqlx::test] integration coverage and storage unit tests.
- SvelteKit frontend using Svelte 5 runes, typed API client, Vitest
unit tests and Playwright e2e with route mocking.
- CLAUDE.md documenting layering, TDD/git/SemVer workflow rules, and
extension points (tags, fulltext search, OCR, S3, auth).
- Project-scoped .claude/settings.json with permission allowlist for
the toolchain (git, cargo, npm/vite, docker, psql, gh, doc fetches).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>