This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Free book library w/ Kindle-like reader. Upload EPUB/PDF → parse → SEO pages + offline-first sync.
Live: textstack.app (public) · textstack.dev (admin)
Stack: ASP.NET Core (API + Worker) + PostgreSQL + React + React Native (Expo)
Prerequisites: Docker, .NET 10 SDK, Node.js (.nvmrc), pnpm
CI/CD: Push to main → auto-deploy (no SSG rebuild). SSG is frozen (#992: no nightly rebuild); rebuild on demand via the admin panel, make rebuild-ssg, or a manual deploy with rebuild_ssg.
The project is mid-migration to V2: two databases, one cutover (ADR-030, plan docs/internal/v2/v2-plan.md, umbrella #1013). The public SEO catalog retires at the end (#992 decision; M6).
textstack_v2is a second Postgres DB beside the legacy one: own SQL ledger (make migrate-v2), Dapper repositories,/me/v2/*endpoints behindFeatures:V2Api— off in production, on in CI/dev. The legacy path must never depend on V2 being up.- ETL legacy → V2:
etl-v2CLI, built, validated, rehearsed on real backups (M3 done). Cutover (M5) and legacy retirement (M6, drops EF Core + catalog) are not executed yet. - V2 reader (ADR-029 sandbox, ADR-032 one engine): our foliate-js fork (
mrviduus/foliate-js, pinned by SHA) built byapps/mobile/src/lib/v2reader/build.mjsinto a committed generated page under a nonce CSP. Hosts: Android dev route (__DEV__) and web/dev/reader-v2(dev server only) and/:lang/library/my/:id/read-v2(behindFeatures:V2Api). The web build shipsdist/reader-v2/= a no-store frame shell (nonce filled by nginxsub_filter) + the content-hashed page script, asserted on build; nginx locations intextstack.conf, checked byprodCsp.check.mjs. Own README inapps/mobile/src/lib/v2reader/. Replacespackages/reader-engineonly at R8 — until then the engine rules below still hold. - Work runs in waves: kickoff files
.claude/wave-<N>-prompt.md(pipeline, quality bar, standing rules); current state indocs/STATUS.mdand #1013 checkboxes. Locator schemes and roles: ADR-031.
The PR is the changelog (#1220). Readers see the /changelog page (merged PRs by week; public ones in plain words, chosen weekly by /changelog-week). No GitHub releases for the app: Android notes go to Play's "What's new". Nobody writes changelog lines.
| Where | Job |
|---|---|
| The PR | Title = one scannable line. One area label (reading, library, highlights, vocabulary, assistant, account, data, ops, documentation; CI fails without one). Description = the full write-up, article source material. |
CHANGELOG.md |
Frozen. History up to the switch; never add to it. Older halves live in git history only. |
internal/incidents/ |
Postmortems for anything that broke production. Start from _TEMPLATE.md. |
docs/STATUS.md |
Where the project is now: in flight, deliberately-not-doing. Links to issues; never copies them. |
| GitHub issues | Every open item (bug, review finding, deferred work), one issue each, labelled (reader-engine, phase-3-gate, …). A PR closes it with Fixes #N. Public: nothing private. Old STATUS items move to an issue when someone touches them. |
Load-bearing decisions still go to docs/architecture/adr/. Details: .claude/commands/changelog.md.
# Setup (one-time)
cp .env.example .env # Edit with real values
make nginx-setup # Install nginx config (Linux)
make nginx-setup-mac # Mac
make fix-permissions # Create data/library etc. owned by uid 1000 (else V2 uploads fail)
make up # Start services
# Docker
make up / down / restart / logs / status
make build # docker compose up -d --build
make rebuild # full rebuild --no-cache
make clean-ssg # remove dist/ssg*
make fix-permissions # Fix volume permissions
make featured SLUGS="a b c" # Replace the Popular shelf (run on the server; ranks 1..N in order)
make featured-show # Print the current Popular shelf
# After editing .env, `docker compose restart <svc>` does NOT re-read env vars
# (they are baked in at container creation). Use force-recreate:
# docker compose up -d --force-recreate --no-deps <service>
# Deploy
# Deploy: merge to main (deploy.yml). Break-glass: gh workflow run deploy.yml [-f rollback_commit=<sha>]
make rebuild-ssg # Queue a Full SSG rebuild job (ssg-worker), follow it to the end
# Database
make backup # Backup to ~/backups/textstack/
make backup-list # List all backups
make restore FILE=path.gz # Restore from backup
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' # DB shell (user/db come from .env; prod's user is not `app`)
docker compose down -v # Reset all (loses data)
# Tests
dotnet test # All tests (.runsettings excludes Category=Load; none exist today)
dotnet test tests/TextStack.UnitTests
dotnet test tests/TextStack.IntegrationTests
dotnet test tests/TextStack.Extraction.Tests
dotnet test tests/TextStack.Search.Tests
dotnet test tests/TextStack.Persistence.Tests # Dapper repositories on real Postgres (Testcontainers; needs Docker)
dotnet test --filter "Name~TestMethodName" # Single test
pnpm -C apps/web test # Frontend unit tests (Vitest)
pnpm -C apps/web test:watch # Watch mode
pnpm -C apps/web test:e2e # Playwright E2E (headless)
pnpm -C apps/web test:e2e:ui # Playwright E2E (UI mode)
# Lint
dotnet format textstack.sln # Backend
# CLI commands (via dotnet run --project backend/src/Api --)
# create-admin <email> <password> (always Admin: Editor/Moderator collapse to Admin on V2, ADR-031 decided 7)
# optimize-images [--dry-run]
# import-textstack <book-path>
# backfill-pdf-page-counts [--dry-run] (fills null UserBook.PageCount for PDF uploads)
# etl-v2 [--dry-run] [--source <cs>] [--target <cs>] [--source-dump-sha <hex>] (legacy -> textstack_v2 data, v2-plan §5.2, internal/ops/v2-etl.md; not the schema: that is `make migrate-v2`)
# credentials via env ConnectionStrings__Default / ConnectionStrings__V2 or Passfile=, never argv (refused): ConnectionStrings__Default=… make etl-v2 ARGS=--dry-run
# Local dev (no Docker)
# From the repo root; the defaults /storage and /library are container paths. Two siblings, never nested:
export Storage__RootPath=data/storage Storage__LibraryRoot=data/library
dotnet run --project backend/src/Api
dotnet run --project backend/src/Worker
pnpm -C apps/web dev # http://localhost:5173
pnpm -C apps/admin dev # http://localhost:81
# Build
pnpm -C apps/web build
pnpm -C apps/admin build
# Migrations: plain SQL in database/ (database/README.md, ADR-026). New change = NNNN_<name>.up.sql + .down.sql
make migrate # migrator up, with this checkout's database/ mounted (no rebuild needed)
make migrate-status # applied / pending / unknown versions
make migrate-down V=<n> # revert versions above n — schema first, then the code
# Only the migrator migrates (ADR-021); the Api never does. Behind schema → /health/ready 503
# Mobile app (apps/mobile)
cd apps/mobile
npx expo start # Dev server (Expo Go — limited native modules)
npx expo run:ios # Local iOS build (requires Xcode)
npx expo run:android # Local Android build (requires Android Studio)
npx tsc --noEmit # TypeScript check
npm run build:dev:ios # EAS dev build (cloud, requires eas login)
npm run build:prod # EAS production build
npm run submit:ios # Submit to App Store
npm run submit:android # Submit to Google Play| Service | Local | Prod |
|---|---|---|
| Web | http://localhost:5173 | https://textstack.app |
| API | http://localhost:8080 | https://textstack.app/api |
| API Docs | http://localhost:8080/scalar/v1 | — |
| Admin | http://localhost:81 | https://textstack.dev |
| Aspire | http://127.0.0.1:18888 | — |
Storage: ./data/storage/ — catalog {id[..2]}/{id}/{file}, uploads users/{userId[..2]}/{userId}/books/{userBookId}/ (LocalFileStorageService.cs). V2 library: ./data/library/ (/library, Storage:LibraryRoot), outside the public root — library/…, uploads/… (LibraryFileStore.cs).
API → Application → Domain ← Infrastructure
↑
Worker
- Domain: Pure C#, no framework deps
- Application: Business logic, interfaces (
IAppDbContext,IFileStorageService) - Contracts: Shared DTOs (request/response models) used by API and Application
- Infrastructure: EF Core (snake_case naming), storage implementations
- API/Worker: Orchestration, DI
Backend class libraries (backend/src/, beyond the layers above): Extraction (EPUB/PDF parsers), Search (FTS providers), Tts (Edge TTS), Vocabulary (DistractorGenerator), Ai/ (TextStack.Ai.*: LLM client, agents, tools, evals, MCP server). There is no EPUB builder any more — TextStack.Epub was deleted 2026-09-28 with the export it existed for.
Cross-platform TS code shared by both web and mobile, consumed via source path-aliases (NOT published / built) — apps/web resolves them in vite.config.ts + tsconfig.json; mobile via its bundler config.
@textstack/shared(packages/shared/src/) — the canonical home for platform-agnostic logic:api/(client),types/api,i18n/,anon/,reader/(bookProgress, progressPayload, continueReading),vocabLevel,vocabularyConstants,lib/pathPrefix. Edit here, not in app copies, when changing logic both clients need.@textstack/reader-engine(packages/reader-engine/) — the reader engine (ADR-025): the reader's rendering and position; mobile (reflow + PDF) and web read only through it; old readers deleted in Phase 6 (#848 web, #866 mobile, #868 cleanup). Own rules in itsCLAUDE.md, own agent.claude/agents/reader-engine.md, built test-first.
Middleware pipeline (order matters): ForwardedHeaders → Cors → AppVersion (mobile X-App-Version → log scope + Sentry tag) → ExceptionMiddleware → UserStoragePolicy (404 for an upload's files except its cover; must precede StaticFiles) → StaticFiles(/storage) → /health → SiteContext → LanguageContext → Routing → McpKeyAuth → RateLimiter (must follow Routing, or per-endpoint policies are inert) → GuestActivity (LastActiveAt, 10 min debounce) → AdminAuth (conditional on /admin/*) → PathGates (/me/* → 401 without a user, /internal/* → 403 off the docker network; ADR-024)
User routes live under /me; /internal is local-only by middleware. Every other route is listed in tests/TextStack.UnitTests/Routes/routes.public.txt — a new public route fails PublicRoutesSnapshotTests until it is added there.
Site resolution: Single-site permanent (ADR-007). SiteContextMiddleware resolves host → SiteId. The single site id is exposed process-wide via ICurrentSite (config Site:Id, default SiteConstants.DefaultSiteId); EF global query filters key on it (see ISiteScoped). The dev ?site= override was removed (R1b) — internal host-less callers (e.g. ssg-worker.mjs) must send a resolvable Host header.
Patterns:
- Endpoints:
Map{Domain}Endpoints()inApi/Endpoints/ - Test naming:
{Method}_{Scenario}_{Expected}
No Redux/Zustand — React Context only. Provider hierarchy in App.tsx:
BrowserRouter → SiteProvider → AuthProvider → GuestLimitsProvider → NativeLanguageProvider → DownloadProvider → AppRoutes
└─ /:lang/* → LanguageProvider → Header + page routes
- SiteProvider: Fetches
/api/site/context, providessiteto all children - AuthProvider: Google Sign-In, email/password, Apple auth, auto-refresh token, skips Google for bots
- GuestLimitsProvider: Tracks guest usage limits (pages read, words saved) before requiring sign-up
- NativeLanguageProvider: User's native language for translations/definitions direction
- DownloadProvider: Offline reading — IndexedDB cache, download progress, resume
- LanguageProvider: Inside language routes only. Extracts
langfrom URL params, providesswitchLanguage(),getLocalizedPath()
Context files: apps/web/src/context/{Site,Auth,GuestLimits,NativeLanguage,Download,Language}Context.tsx
i18n: JSON file in apps/web/src/locales/en.json. Hook: useTranslation(). Languages: ['en'].
Routing: Language-prefixed routes (/:lang/books, /:lang/authors, etc). Root / → /en.
API client: useApi() hook → createApi(language) → methods like getBooks(), getBook(slug). Uses fetchJsonWithRetry().
API client layer: apps/web/src/api/ — modules incl. client.ts (base; runs @textstack/shared's api client in cookie mode via initApi({ credentials: 'include' })), auth.ts, explain.ts, oauth.ts, readingTracking.ts, reviewQuestions.ts, translation.ts, tts.ts, userBooks.ts, userData.ts, vocabulary.ts. useApi() hook wraps these.
Hooks (apps/web/src/hooks/), e.g. Reader: useReadingSession, useReadingProgress, useReaderKeyboard, useReaderSettings, useReaderVocabulary, useImmersiveMode, useInBookSearch, useTextSelection, useTextTranslation, useDarkMode. Library/data: useLibrary, useBookmarks, useHighlights, useBookStats, useVocabulary, useVocabularyReview, useVocabDailyStats, useReadingStats, useReadingGoals, useAchievements. UI: useFocusTrap, useIsMobile, useScrolled, useDebounce, useSoundEffects, useCardAnswer, useQuickStats. Network: useNetworkRecovery.
Admin panel: Separate React app (apps/admin/), English-only, JWT auth. Pages: Dashboard, Upload, User Uploads, Jobs queue, Editions list/edit, Authors CRUD, Genres CRUD, Chapter editor, SSG rebuild + job detail, Auto Publish, SEO Backfill, Book Quality, AI Quality, Tools, Settings.
PostgreSQL FTS only: raw SQL (Dapper) in TextStack.Search/Providers/PostgresFts/PostgresSearchProvider.cs, querying chapters/editions directly. chapters.search_vector is maintained by a DB trigger — there is no indexer and nothing to reindex. (The write-only search_documents copy, its reindex-search CLI and the never-deployed Meilisearch provider were deleted 2026-10-01.)
After schema changes:
- Update
PostgresSearchProvider - Run
dotnet test tests/TextStack.IntegrationTests --filter SearchEndpoint - Test:
https://textstack.app/en/search?q=test
tests/
├── TextStack.UnitTests/ # Pure logic, no DB
├── TextStack.IntegrationTests/ # API tests against running server (LiveApiFixture → localhost:8080, override via API_URL env)
├── TextStack.Extraction.Tests/ # Book parsing (EPUB/PDF)
├── TextStack.Search.Tests/ # Search logic
├── TextStack.Persistence.Tests/ # Dapper repositories, Testcontainers Postgres built by database/migrate.sh (ADR-028)
├── TextStack.AiEvals/ # Eval runners on fake LLMs; live evals skip w/o OPENAI_API_KEY
├── TextStack.Ai.Mcp.Tests/ # MCP over-the-wire (loopback)
apps/web/e2e/ # Playwright E2E (chromium, mobile, admin projects)
apps/mobile/e2e/ # Mobile Playwright E2E
Test naming convention: {MethodName}_{Scenario}_{ExpectedResult}
E2E setup: Global setup authenticates test user + admin, discovers books from API → .test-data.json. Auth state stored in apps/web/e2e/.auth/. Page object helpers in apps/web/e2e/helpers/.
Running the integration suite locally: it trips its own rate limits at production values — a dozen classes seed a book by clipping one, and the GDPR-delete class calls account-delete four times. CI raises the knobs; do the same locally or you will read 429s as failures:
make migrate-v2 # once: the textstack_v2 database the V2 classes (tests/…/V2) need
CLIP_PERMIT_LIMIT=200 ACCOUNT_DELETE_PERMIT_LIMIT=50 GUEST_SESSION_PERMIT_LIMIT=50 \
USER_LOGIN_PERMIT_LIMIT=100 DEVICE_CODE_PERMIT_LIMIT=50 MCP_KEYS_PERMIT_LIMIT=100 FEATURES_V2API=true \
docker compose up -d --no-deps --force-recreate api
# once per database: editions ChapterReviewEndpointTests needs (else it skips); idempotent
docker compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB"' \
< tests/TextStack.IntegrationTests/Fixtures/chapter-review-editions.sql
V2_API_TESTS=1 dotnet test tests/TextStack.IntegrationTests # without V2_API_TESTS the V2 classes skip
# DB-backed tests (DeviceAuth, RefreshTokenHardening, …) skip unless Postgres is reachable: start the
# stack with `-f docker-compose.yml -f .github/docker-compose.ci-db.yml` and set
# TEST_DB_CONNECTION="Host=localhost;Port=5432;Database=<db>;Username=<user>;Password=<pw>"
docker compose up -d --no-deps --force-recreate api # back to production valuesAlso: the windows are 1–5 minutes, so two overlapping runs throttle each other. Run the suite once and read the result, rather than re-running to confirm a failure.
Test env vars:
ENABLE_TEST_AUTH=true— enables test auth endpoints (needed for integration + E2E)ADMIN_EMAIL/ADMIN_PASSWORD— needed for admin E2E- Integration tests set
Hostheader:general.localhost(public),textstack.dev(admin)
Vocabulary E2E tests (apps/web/e2e/tests/vocabulary.spec.ts): serial suite; beforeAll logs in, wipes the test user's words and saves 3 common words via the API (inline TEST_WORDS — rare words would route to WordLookup and never reach the SRS list). Covers: flashcards is the default mode, the streak badge shows when words are due, a review session starts in flashcard mode.
Supported formats: EPUB, PDF. Processing order: Spelling → Hyphenation → Typography → Semantic. Details in backend/src/Extraction/TextStack.Extraction/RULES.md. ARM64 caveat: uses compiled Regex not [GeneratedRegex] (SIGILL bug).
Central versioning via Directory.Packages.props — don't add <Version> in individual csproj files. Target: net10.0 (set in Directory.Build.props).
chapter_numberis an ordering key, not a display ordinal (some editions start at 0, some at 1 or 2). Show a chapter's title; use the number only to sort.- Mobile reading path waits for neither a token nor a network — chapter loaders read SQLite first, then refresh from the server (
chapterLoadOrder.test.ts). Nothing on the reading path may need an account. offlineDb.web.tsmirrors every export ofofflineDb.ts, or the web bundle (which mobile e2e runs against) fails to resolve.- Guest = a real session.
isAuthenticatedisuser !== null; account-only questions go throughcapabilitiesFor(user)(mobilecapabilities.ts), never inlineuser?.isGuest(ADR-014). - MCP tool count is asserted in four tests and descriptions are mirrored verbatim in
Contracts/Mcp/McpManifest.cs— change all together. - Docs, comments, ADRs, commits, PRs: English only. Cyrillic is allowed only as real input data (tests, fixtures, seed migrations, translation examples).
- Never deploy by SSH. Deploy = merge to
main; break-glassgh workflow run deploy.yml.
| Topic | File |
|---|---|
| Features: entities, user books, translate/explain/TTS, vocabulary SRS, guests, auth, auto publish, SEO backfill, SSG | internal/claude/features.md |
| Every API route group | internal/claude/api-endpoints.md |
| Where things live (key files table) | internal/claude/key-files.md |
| Mobile: Expo, offline, reading path, Play/OTA release | internal/claude/mobile.md |
| CI/CD, deployment, nginx, MCP server, telemetry, verifying SSG | internal/claude/operations.md |