# CLAUDE.md This file provides guidance to Claude Code when working with code in this repository. It holds only what applies regardless of which part of the codebase is being touched. Deep, subsystem-specific detail lives in nested `CLAUDE.md` files placed inside the relevant directory - Claude Code auto-loads a nested file only when a file under that directory is read or edited, so the always-loaded cost of this repository stays proportional to this file alone. See "Subsystem map" below for the full list. ## Project DevPlace is a server-rendered social network for developers. FastAPI backend serves Jinja2 templates with pure ES6 module JavaScript on the frontend. SQLite via the `dataset` library (auto-syncs schema). No JS framework, no NPM, no JWT. - **Database:** `dataset` (auto-syncs schema, uses `uid` for PKs). SQLite. - **Auth:** `session` cookie, plus `X-API-KEY` / `Authorization: Bearer ` / HTTP Basic (username-or-email:password) - all resolved in `get_current_user`. PBKDF2-SHA256 via passlib. No JWT. Every user has an `api_key` (uuid7), set at signup and backfilled in `init_db`/`devplace apikey backfill`. Docs site at `/docs` (`routers/docs/` package, `DOCS_PAGES` registry; FastAPI's Swagger is moved to `/swagger` so `/docs` is free). - **Static:** `devplacepy/static/` mounted at `/static`; URLs are boot-versioned (`/static/v/...`) via `static_url`/`assetUrl` and served immutable for a year. - **Templates:** `devplacepy/templates/`. Shared `templates` instance from `devplacepy.templating` - all routers import from there, never instantiate their own. - **Ports:** 10500 (dev), 10501 (tests; the serial suite uses a single uvicorn subprocess). - **Username:** letters, numbers, hyphens, underscores only, 3-32 chars. **Password:** minimum 6 chars. - **Avatars:** Multiavatar SVG generated locally from a seed in <5ms, in-memory cache cleared on restart, fallback to initial-based SVG on error. URL `/avatar/multiavatar/{seed}?size={size}`. The seed is per-user: the nullable `users.avatar_seed` column overrides the username. Always resolve it through the single null-safe choke point `avatar.avatar_seed(user)` (a Jinja global) - `user.get("avatar_seed") or user.get("username")` - never read `avatar_seed` or pass `username` to `avatar_url(...)` directly; every render site (the `_avatar_link.html` partial, `og_image`, the devRant avatar payload, etc.) goes through it. Regenerate is owner-or-admin at `POST /profile/{username}/regenerate-avatar` (writes a fresh `generate_uid()`, invalidates the user cache, audits `profile.avatar.regenerate`); the old seed is never stored, so the old avatar cannot return. Devii tool `regenerate_avatar` (`CONFIRM_REQUIRED`). ## Commands ```bash make install # pip install -e . + playwright install chromium make ppy # build the single shared container image (ppy:latest); run once before launching instances make dev # uvicorn --reload on port 10500, backlog 4096 make prod # uvicorn --workers $(WEB_WORKERS) (defaults to nproc), port 10500 (backlog 8192) make test # Playwright + unit tests, headless, serial (one at a time), -x fail-fast make test-headed # same tests in a visible Chromium window (single process) make locust # Locust load test, interactive web UI make locust-headless # Locust CLI mode for CI ``` The Makefile exports `PYTHONDONTWRITEBYTECODE=1` for every recipe, so no `.pyc` files or `__pycache__` directories are written by any `make` target. Keep it that way - do not add a target that re-enables bytecode writing. `make clean` removes any stray bytecode left from running Python outside make. Validate code without running the suite: confirm `python -c "from devplacepy.main import app"` imports clean and check each touched language manually (Python compiles/imports, JS parses, CSS braces and HTML tags balance). Single test: `python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x` CLI (installed as `devplace`): ```bash devplace role get devplace role set devplace apikey get # print a user's API key devplace apikey reset # regenerate a user's API key devplace apikey backfill # assign API keys to users that lack one devplace token issue [--label L] # issue a DevPlace access token devplace token list # list a user's active access tokens devplace token revoke # revoke a single access token by uid devplace token revoke-all # revoke all access tokens for a user devplace token prune # soft-delete all expired access tokens devplace news clear # delete all news rows devplace news sanitize # strip HTML from news descriptions/content devplace attachments prune # remove orphan attachment records/files devplace devii reset-quota # reset one user's rolling 24h AI quota devplace devii reset-quota --guests # reset every guest quota devplace devii reset-quota --all # reset every quota (users and guests) devplace zips prune # delete expired zip archives + job rows devplace zips clear # delete every zip archive + job row devplace forks prune # delete expired completed fork job rows (forked projects persist) devplace forks clear # delete every fork job row (forked projects persist) devplace seo prune # delete expired SEO audit reports + job rows devplace seo clear # delete every SEO audit report + job row devplace seo-meta prune # delete expired SEO metadata job rows (generated metadata persists) devplace seo-meta clear # delete every SEO metadata job row (generated metadata persists) devplace deepsearch prune # delete expired DeepSearch sessions + job rows + collections devplace deepsearch clear # delete every DeepSearch session + job row + collection devplace isslop analyze # run a AI usage analysis from the terminal (report persists) devplace isslop prune # delete expired AI usage analysis job rows (analyses + reports persist) devplace isslop clear # delete every AI usage analysis, its report and job rows devplace backups list # list recorded backups devplace backups run # enqueue a backup (processed by the running server) devplace backups prune # remove backup records whose archive file is missing devplace backups clear # delete every backup archive + record devplace containers list # list container instances devplace containers reconcile # run one reconcile pass (desired vs docker ps) devplace containers prune # reap orphan containers + dangling images devplace containers prune-builds # remove legacy per-project images + clear dockerfiles/builds tables (one-time) devplace containers gc-workspaces # remove workspace dirs with no instances devplace emoji-sync # regenerate static/js/emoji-shortcodes.js from the emoji library (run after an emoji dep bump) devplace migrate-data # relocate legacy runtime files into data/ (idempotent; --dry-run to preview) ``` ### Runtime data layout (single source of truth) Every runtime/user-generated artifact lives under one root, `config.DATA_DIR` (`DEVPLACE_DATA_DIR`, default `/data`). `config.py` derives every runtime path from it and lists them in the `DATA_PATHS` registry; `ensure_data_dirs()` creates the whole tree before anything is written. **No module computes a runtime path from scratch** - import the constants (`UPLOADS_DIR`, `ATTACHMENTS_DIR`, `PROJECT_FILES_DIR`, `ZIPS_DIR`, `ZIP_STAGING_DIR`, `FORK_STAGING_DIR`, `KEYS_DIR`, `BOT_DIR`, `LOCKS_DIR`, `CONTAINER_WORKSPACES_DIR`, `DEVII_TASKS_DB`, `DEVII_LESSONS_DB`, `VAPID_*_FILE`, `SERVICE_LOCK_FILE`, `INIT_LOCK_FILE`). Uploads are physically under `data/uploads/` but still served at the unchanged `/static/uploads/` URL. Stored DB values for attachments/project files are relative (`directory` + `stored_name`), so no DB rewrite is ever needed for a layout change. **Blob sharding (`attachments._directory_for`, reused by `attachments`, `project_files`, `zip_service`):** blobs are spread into a two-level `xx/yy` tree keyed on the **random tail** of the uuid7 (`tail[-2:]/tail[-4:-2]`), NEVER the leading bytes - a uuid7's first 48 bits are a millisecond timestamp, so prefix-sharding a time-ordered id funnels every contemporaneous write into one bucket. Any new shard helper MUST shard on a high-entropy field (uuid tail or a hash), never the head. Forward-only: pre-existing rows keep resolving from their stored `directory`/`local_path`. Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA_DIR`, `DEVPLACE_DISABLE_SERVICES=1`. `make test` runs **serially, one test at a time, in a single process**, enforced centrally in `pyproject.toml` (pytest-xdist is not a dependency, `-n` is rejected). ## Environment variables | Var | Default | Purpose | |-----|---------|---------| | `DEVPLACE_DATABASE_URL` | `sqlite:////data/devplace.db` | Override DB path (tests use this) | | `SECRET_KEY` | hardcoded fallback | Session signing | | `DEVPLACE_DISABLE_SERVICES` | unset | When `1`, NewsService and other background services skip start (set by test conftest) | | `PLAYWRIGHT_HEADLESS` | `1` in tests | Toggle headed mode | | `DEVPLACE_TEMPLATE_AUTO_RELOAD` | `1` (on) | Jinja template auto-reload. `1` stat-checks every template per render (dev hot-reload); set `0` in production so compiled templates stay cached in memory. | | `DEVPLACE_WEB_WORKERS` / `--workers` | `nproc` (prod) | Uvicorn worker count; `make prod WEB_WORKERS=N` to override. | | `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | How long after a user's last activity they still count as online. `config.PRESENCE_WRITE_SECONDS` (half of it) throttles `last_seen` writes per worker. | | `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. | | `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. | | `DEVPLACE_DATA_DIR` | `/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. | ## Subsystem map Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file in that directory is touched): | Path | Covers | |------|--------| | `devplacepy/routers/CLAUDE.md` | Full URL prefix map, route aggregation rules, HTML/JSON negotiation, FastAPI patterns, gists, feed/listing features, engagement (reactions/bookmarks/polls/heatmap/follow/block-mute), SEO implementation, polymorphic comments/votes | | `devplacepy/routers/projects/CLAUDE.md` | Project detail page, virtual filesystem routes, visibility/read-only UI, deletion confirmation | | `devplacepy/routers/docs/CLAUDE.md` | The `/docs` documentation site: `DOCS_PAGES`, audience tiers, prose rendering, API tester | | `devplacepy/routers/devrant/CLAUDE.md` | devRant-compatible REST API (`/api`) | | `devplacepy/services/containers/CLAUDE.md` | Container manager: backend, security, `ppy` image, ingress, terminals, sync | | `devplacepy/services/devii/CLAUDE.md` | Devii assistant: sessions/channels, scheduler, virtual tools, self-configured behavior, client browser tools | | `devplacepy/services/openai_gateway/CLAUDE.md` | AI gateway: `/openai/v1/*`, usage ledger, provider/model routing | | `devplacepy/services/jobs/CLAUDE.md` | Async job services: zip, fork, SEO diagnostics, SEO metadata, DeepSearch, AI Usage Analyzer | | `devplacepy/services/audit/CLAUDE.md` | Audit log: recorders, categories, retention | | `devplacepy/services/backup/CLAUDE.md` | Backup service: targets, worker, schedules, primary-admin-only download | | `devplacepy/services/telegram/CLAUDE.md` | Telegram bot bridge | | `devplacepy/services/email/CLAUDE.md` | Devii IMAP/SMTP email tools | | `devplacepy/services/gitea/CLAUDE.md` | Issue tracker (Gitea-backed, no local issue store) | | `devplacepy/services/messaging/CLAUDE.md` | Real-time DM chat (WS + relay) | | `devplacepy/services/xmlrpc/CLAUDE.md` | XML-RPC bridge | | `devplacepy/services/news/CLAUDE.md` | `NewsService` import pipeline | | `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet | | `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API | | `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus | | `devplacepy/services/game/CLAUDE.md` | Code Farm idle game | | `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` | | `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings | | `devplacepy/utils/CLAUDE.md` | Notifications and gamification (XP/levels/badges/leaderboard) | | `devplacepy/static/js/CLAUDE.md` | Custom web components, shared frontend utilities (Http/Poller/FloatingWindow/ScrollMemory) | | `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials | | `tests/CLAUDE.md` | Detailed testing patterns and pitfalls | `isslop/` at the repo root is a separate, standalone sibling project (own `pyproject.toml`, `Makefile`, port 18732) with its own `isslop/CLAUDE.md` - it is not a nested subsystem of the `devplacepy` package. The integrated engine that DevPlace actually runs (`devplace isslop analyze`, the `/tools/isslop` job service) is a distinct implementation documented in `devplacepy/services/jobs/CLAUDE.md`. ## Architecture ### Request pipeline `main.py` mounts `/static`, registers every router with its prefix, installs middlewares (security headers, a per-IP rate limit in an in-process `defaultdict`, a maintenance gate), and registers global 404/500 handlers. Rate limiting reads `rate_limit_per_minute`/`rate_limit_window_seconds` from `site_settings` (default 60/60s) and **applies only to mutating methods** (`POST`/`PUT`/`DELETE`/`PATCH`); reads and the whole `/openai` gateway are exempt. Bucket key is `X-Real-IP` falling back to `request.client.host`; an over-limit request gets `429` with `Retry-After`. The maintenance middleware short-circuits non-admin requests with a 503 when `maintenance_mode="1"`, but always allows `/static`, `/avatar`, `/auth`, `/admin`, and admin users. The outermost middleware is `response_timing`: stamps `request.state.request_start` and sets `X-Response-Time` on every response; the `response_time_ms(request)` Jinja global renders it as a fixed bottom-left badge in `base.html`. This is the single generic timing mechanism - never re-time per route. `@app.on_event("startup")` calls `init_db()` and, unless `DEVPLACE_DISABLE_SERVICES` is set, registers `NewsService` and kicks off `start_all()`. `GET /` never redirects: guests get the marketing splash, authenticated users get a personalized home, both sharing Latest Posts + Developer News. The feed and Latest Posts enforce **author diversity** by interleaving authors (never dropping posts) via `interleave_by_author`/`paginate_diverse` in `database/`. ### Routing layout Routers in `devplacepy/routers/` are organised as a **directory tree that mirrors the endpoint (URL) path**, exactly like `tests/`. A domain with a single resource stays one flat file (`feed.py`, `posts.py`, ...); a domain with several sub-resources is a **package directory** split one file per sub-resource. Each leaf declares its own `router = APIRouter()`; the package `__init__.py` aggregates with `router.include_router(...)`. A leaf owning the domain's collection-root (`""`) route must be the package's base router (FastAPI rejects an empty path under an empty prefix). Full elaboration, per-domain detail, and the complete deep-dive live in `devplacepy/routers/CLAUDE.md` - read it before touching routing. Compact map: | Prefix | Router | |--------|--------| | `/auth` | auth/ package | | `/feed`, `/posts`, `/comments` | flat files | | `/projects`, `/projects/{slug}/files`, `/projects/{slug}/containers` | projects/ package - see `routers/projects/CLAUDE.md` | | `/profile` | profile/ package (customization, notifications, ai-correction, ai-modifier, telegram, usage) | | `/messages` | messages.py - see `services/messaging/CLAUDE.md` | | `/notifications`, `/votes`, `/reactions`, `/bookmarks`, `/polls`, `/avatar`, `/follow`, `/leaderboard` | flat files | | (none) | relations.py - block/mute | | `/admin`, `/admin/services`, `/admin/containers` | admin/ package | | `/issues` | issues/ package - see `services/gitea/CLAUDE.md` | | `/gists`, `/news`, `/uploads`, `/media` | flat files | | `/openai` | openai_gateway.py - see `services/openai_gateway/CLAUDE.md` | | `/devii` | devii.py - see `services/devii/CLAUDE.md` | | `/zips`, `/forks` | see `services/jobs/CLAUDE.md` | | `/tools` | tools/ package (SEO diagnostics, DeepSearch, AI Usage Analyzer) - see `services/jobs/CLAUDE.md` | | `/p/{slug}` | proxy.py - container ingress reverse proxy | | `/xmlrpc` | xmlrpc.py - see `services/xmlrpc/CLAUDE.md` | | `/api` | devrant/ package - see `routers/devrant/CLAUDE.md` | | `/dbapi` | dbapi/ package, **primary-administrator-only, strictly READ-ONLY** - see `services/dbapi/CLAUDE.md` | | `/game` | game/ package - see `services/game/CLAUDE.md` | | (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) | | `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` | | (none) | seo.py - `/robots.txt`, `/sitemap.xml` | ### Templates and frontend - All routers MUST import the shared `templates` from `devplacepy.templating`. Do NOT instantiate `Jinja2Templates` per router. - Templates extend `base.html`; page CSS via `{% block extra_head %}`, page JS via `{% block extra_js %}`. - **Nav active state is the `nav_active` Jinja global**, never an inline path check. - **Static asset URLs are boot-versioned:** never hardcode a bare `/static/...` href/src - use `static_url(path)` (Jinja) / `assetUrl(path)` (JS). - All JS is ES6 modules, one class per file, instantiated as `app`. Custom web components (`dp-*` prefix) and shared frontend utilities (`Http`, `Poller`, `JobPoller`, `OptimisticAction`, `FloatingWindow`, `ScrollMemory`) are documented in `devplacepy/static/js/CLAUDE.md` - reuse them, never re-implement. - CDN scripts in `base.html` MUST use `defer` or `type="module"`. - Modal system, shared template partials (`_avatar_link.html`, `_user_link.html`, `_sidebar_search.html`) are documented in `devplacepy/templates/CLAUDE.md`. ### Content rendering pipeline **Server-rendered content and titles are rendered on the BACKEND for SEO** (`devplacepy/rendering.py`, Jinja globals `render_content(text)`/`render_title(text)`). This is the default for all content that exists at request time (post bodies, comments, news, project/gist descriptions, DM history, every content title). It mirrors the client pipeline using **mistune**: em-dash normalization, emoji shortcodes, GFM markdown (`escape=True` - the server XSS control), then a media pass turning bare URLs into embeds and `@mentions` into links. Both are `@lru_cache`d. Server-rendered code blocks get syntax highlighting + Copy button client-side via `ContentEnhancer`. The CLIENT pipeline (`ContentRenderer.js`, `dp-content`/`dp-title`) is retained ONLY for genuinely live/dynamic content that does not exist at request time (live comments, DM bubbles, Devii/DeepSearch/Docs chat, planning report). Runs `marked` -> **`DOMPurify.sanitize`** (the client XSS control, fail-closed) -> highlight.js -> media/autolink. **Do NOT add `data-render` to server-rendered content - call `render_content`/`render_title` instead.** **Emoji shortcodes are the full GitHub/Discord `:name:` set** (~4869 names), generated once from the `emoji` library by `rendering.py` `build_emoji_shortcodes()`; the frontend gets the identical map via the generated `static/js/emoji-shortcodes.js` (regenerate with `devplace emoji-sync` after bumping the dependency, never hand-edit). **Template em-dash normalization:** the shared `templates.env.template_class` runs every rendered template's final HTML through `normalize_em_dash` - the em-dash character and its HTML entity forms all become a plain hyphen, application-wide. One hook; never re-strip em-dash per template. ### Auth Session cookies named `session`, value a 64-char hex token. Passwords hashed with `pbkdf2_sha256` via passlib. `get_current_user(request)` returns user dict or `None`, per-process TTL cache (300s) keyed by token. `require_user(request)` raises a 303 redirect to `/` for guests; `require_admin(request)` redirects to `/feed` for non-admins. Public pages use `get_current_user` so guests can browse - POSTs are all guarded by `require_user`. ### Database SQLite via `dataset.connect` with WAL + 30s busy timeout + 256MB mmap. **SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is called directly inside async route handlers - the database is a local file tuned with WAL/`synchronous=NORMAL`/8MB page cache/256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, wrap calls in a threadpool/`run_in_executor`/`to_thread`, or raise synchronous DB access as a blocking-I/O concern - this is a settled design decision, not open for revisiting. `init_db()` is idempotent and **ensures the full column set of any table code filters on** before creating indexes - `dataset` gives a lazily-created table only the columns of its first insert, so a partial insert elsewhere would leave a reduced schema that later 500s with `no such column`. Full dataset rules, indexing conventions (the soft-delete planner trap), site settings, and the complete table list live in `devplacepy/database/CLAUDE.md`. Runtime config lives in `site_settings`, read via `get_setting(key, default)`/`get_int_setting(key, default)` (60s TTL cache, cross-worker invalidated via `cache_state`). Batch helpers (`get_users_by_uids`, `get_comment_counts_by_post_uids`, `get_vote_counts`, `load_comments`) exist specifically to avoid N+1 - use them instead of per-row lookups. ### Per-user site customization (CSS/JS) Users and guests inject their own CSS and JS, scoped to a page type or globally, configured **conversationally through Devii only** (no HTTP routes - persistence is owner-scoped from the trusted Devii session, like `LessonStore`). It runs solely in that owner's own browser sessions (self-XSS, like a userscript) - never in anyone else's view. - **Page type** = the matched route template (`page_type_for(request)`, e.g. `/posts/{slug}`) - one value covers all instances of a type. **Owner** = `owner_for(request)`: user uid, else the `DEVII_GUEST_COOKIE`, else none. - **Storage:** table `user_customizations` (per `owner_kind`/`owner_id`/`scope`/`lang`); `get_custom_overrides` merges global-then-page-type, cached under the `"customizations"` cache-version name. - **Per-user suppression toggles** (distinct from the site-wide `customization_enabled` kill-switch): `cust_disable_global`/`cust_disable_pagetype` columns let a user hide their own customizations without deleting rows, edited at `POST /profile/{username}/customization/{global|pagetype}` (owner or admin). - **Robustness against hostile CSS:** app chrome (`.topnav`, overlays, modals, context menu, toast host, FAB, lightbox, devii window) is layer-promoted with `will-change: transform` so a user's `position: fixed`/`background-attachment: fixed` CSS cannot trigger a Chromium compositing bug that wipes fixed UI. Any new always-on fixed UI element must join one of these groups. - **Injection:** `custom_css_tag(request)` emits a `