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, usesuidfor PKs). SQLite. - Auth:
sessioncookie, plusX-API-KEY/Authorization: Bearer <api_key>/ HTTP Basic (username-or-email:password) - all resolved inget_current_user. PBKDF2-SHA256 via passlib. No JWT. Every user has anapi_key(uuid7), set at signup and backfilled ininit_db/devplace apikey backfill. Docs site at/docs(routers/docs/package,DOCS_PAGESregistry; FastAPI's Swagger is moved to/swaggerso/docsis free). - Static:
devplacepy/static/mounted at/static; URLs are boot-versioned (/static/v<ts>/...) viastatic_url/assetUrland served immutable for a year. - Templates:
devplacepy/templates/. Sharedtemplatesinstance fromdevplacepy.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 nullableusers.avatar_seedcolumn overrides the username. Always resolve it through the single null-safe choke pointavatar.avatar_seed(user)(a Jinja global) -user.get("avatar_seed") or user.get("username")- never readavatar_seedor passusernametoavatar_url(...)directly; every render site (the_avatar_link.htmlpartial,og_image, the devRant avatar payload, etc.) goes through it. Regenerate is owner-or-admin atPOST /profile/{username}/regenerate-avatar(writes a freshgenerate_uid(), invalidates the user cache, auditsprofile.avatar.regenerate); the old seed is never stored, so the old avatar cannot return. Devii toolregenerate_avatar(CONFIRM_REQUIRED).
Commands
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):
devplace role get <username>
devplace role set <username> <member|admin>
devplace apikey get <username> # print a user's API key
devplace apikey reset <username> # regenerate a user's API key
devplace apikey backfill # assign API keys to users that lack one
devplace token issue <username> [--label L] # issue a DevPlace access token
devplace token list <username> # list a user's active access tokens
devplace token revoke <token_uid> # revoke a single access token by uid
devplace token revoke-all <username> # 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 <username> # 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 <url> # 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 <database|uploads|keys|full> # 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 <repo>/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:///<repo>/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 |
<repo>/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
templatesfromdevplacepy.templating. Do NOT instantiateJinja2Templatesper router. - Templates extend
base.html; page CSS via{% block extra_head %}, page JS via{% block extra_js %}. - Nav active state is the
nav_activeJinja global, never an inline path check. - Static asset URLs are boot-versioned: never hardcode a bare
/static/...href/src - usestatic_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 indevplacepy/static/js/CLAUDE.md- reuse them, never re-implement. - CDN scripts in
base.htmlMUST usedeferortype="module". - Modal system, shared template partials (
_avatar_link.html,_user_link.html,_sidebar_search.html) are documented indevplacepy/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_cached. 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 theDEVII_GUEST_COOKIE, else none. - Storage: table
user_customizations(perowner_kind/owner_id/scope/lang);get_custom_overridesmerges global-then-page-type, cached under the"customizations"cache-version name. - Per-user suppression toggles (distinct from the site-wide
customization_enabledkill-switch):cust_disable_global/cust_disable_pagetypecolumns let a user hide their own customizations without deleting rows, edited atPOST /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 withwill-change: transformso a user'sposition: fixed/background-attachment: fixedCSS 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<style>afterextra_head;custom_js_tag(request)emits a JSON island run vianew Function(JSON.parse(...))afterApplication.js. Both fail closed to empty Markup - a customization bug must never break page render. Two kill-switches:customization_enabled,customization_js_enabled. - Devii (
services/devii/customization/,handler="customization"):customize_list/get/set_css/set_js/reset(requires_auth=False) pluscustomize_set_enabled(the suppression toggles). Set/reset are inCONFIRM_REQUIRED, forcing Devii to ask page-type vs global beforeconfirm=true. Devii previews live withrun_js, thenreload_page.
Background task queue, AI correction/modifier, background services
services/background.py background.submit(fn, *args) is a generic fire-and-forget offload onto one per-worker asyncio.Queue; when the consumer isn't running (tests, full queue) it runs the callable inline, so audit/notification/XP writes stay deterministic for the suite while production defers them. It is the choke point for every audit-log write, every XP award, and every notification - handlers call award_rewards/create_notification directly (never wrap them in background.submit, that double-queues). AI content correction (opt-in, off by default) and the AI modifier (@ai <instruction> inline directive, on by default) rewrite user prose via the internal gateway; sync apply mode never blocks the event loop (run_in_executor + the await_pending_corrections middleware). BaseService/ServiceManager provide the async run loop and singleton registry for background services (NewsService, GatewayService, DeviiService, BotsService, container/audit/telegram reconcilers). Full detail on all of this, plus presence and the live view relay, is in devplacepy/services/CLAUDE.md.
Container manager, Devii assistant, AI gateway, async jobs, audit log
DevPlace ships a Docker-backed Container manager (admin-only, one shared ppy:latest image, security-hardened rootless workflow via a sudo superclone and aptroot), the Devii in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the AI gateway (/openai/v1/*, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), async job services (JobService base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only audit log (record/record_system entrypoints, events.md catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested CLAUDE.md files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
Telegram bot, email, devRant compatibility API, issue tracker
Devii is also reachable over Telegram (one supervised long-poller subprocess, channel="telegram" isolated conversation thread), can drive a user's own external mailbox over IMAP/SMTP (stdlib only, credentials in a soft-deletable table, SSRF-guarded), and DevPlace exposes a devRant-compatible REST API at /api (translates devRant requests onto native posts/comments/votes via reused audited cores, ID mapping is posts.id/comments.id directly). The issue tracker at /issues has no local store - it reads/writes Gitea live via one shared bot token, filing is an async AI-enhanced job. Full detail in the respective nested CLAUDE.md files.
SEO
devplacepy/seo.py generates JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication). Every router builds context via base_seo_context(request, ...). Auth/messages/notifications are noindex,nofollow; profiles with fewer than 2 posts are noindex,follow. /robots.txt and /sitemap.xml are served by routers/seo.py. Full implementation map (template layer, heading hierarchy, slugs, related posts, performance, default OG image, SEO tests) is in devplacepy/routers/CLAUDE.md.
Conventions (project-specific)
- No comments, no docstrings in source. Code is self-documenting.
- Forbidden variable name patterns:
_new,_old,_temp,_v2,better_,my_,the_. - Outbound HTTP goes through the stealth client only. Never instantiate a bare
httpx.AsyncClient(...)/httpx.Client(...)for outbound traffic - build it viadevplacepy.stealth.stealth_async_client(...)/stealth_sync_client(...). The async factory routes bytes throughcurl_cffi(curl-impersonate, BoringSSL) behind anhttpxtransport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport ifcurl_cffiis missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches vianet_guard.guarded_async_client(...)get the fingerprint automatically. Sole exception:routers/proxy.pyrelays the user's own headers verbatim. Load-bearing gotcha: cleartexthttp://is forced to HTTP/1.1 (curl_transport.http_version_for) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns400 "Invalid HTTP request received."once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer callsINTERNAL_GATEWAY_URL=http://localhost:<port>/openai/v1/..., so without this override a large Devii turn (big system prompt + tool list) fails on every request.https://keeps the HTTP/2 default.stealth_sync_clientstays pure-httpx Chrome-aligned (no curl_cffi sync adapter). - Form validation is Pydantic-native. Routers declare
data: Annotated[SomeForm, Form()](models inmodels.py); the globalRequestValidationErrorhandler re-renders auth pages with messages (400) or redirects other routes. Read rawawait request.form()only when also reading an uploaded file alongside a model. RedirectResponse(url=..., status_code=302)- always passstatus_codeas an integer literal, neverstatus.HTTP_302_FOUND.- Ownership checks: compare
resource["user_uid"] == user["uid"]before edit/delete (usecontent.is_owner). Deletes additionally allow any admin:is_owner(...) or is_admin(user)on every content delete endpoint, so an administrator may delete any member's content while members are limited to their own. Edits stay owner-only. Deletes are soft and cascade under one shared stamp - seedevplacepy/database/CLAUDE.md. - All dates shown to users are DD/MM/YYYY (European), rendered in the viewer's own timezone client-side. Timestamps are stored/emitted as UTC ISO. Use the
local_dt(iso, mode)/dt_ago(iso)Jinja globals for any user-facing instant - they emit<time data-dt>andstatic/js/LocalTime.jsreformats to local timezone with aMutationObserverfor dynamic content.format_date()/time_ago()stay as plain-text helpers for JSON responses, no-JS fallbacks, and non-timestamp date fields (e.g. projectrelease_date) - do NOT wrap those inlocal_dt. - Slug + UUID lookup: resources with slugs accept either the slug or the bare UUID via
resolve_by_slug(). Slugs aremake_combined_slug(title, uid), prefixed with the random tail of the UUID (never the leading bytes - same timestamp-collision reasoning as blob sharding). - Roles are stored capitalized:
users.roleis exactly"Admin"or"Member". Always test admin-ness through theis_admin(user)global (case-sensitive== "Admin") - never hand-roll a lowercase compare. Any write tousers.roleMUST calldatabase.invalidate_admins_cache(). - Admin seniority: a junior admin cannot manage a more senior admin. Every per-user mutation in
routers/admin/users.pyis gated by_is_senior_admin(actor, target)- blocks (auditsresult="denied") when the target is an Admin who registered earlier. Server-side, so it also covers Devii's admin tools. - Never pass a
respond()context key that collides with a Jinja global.respond(request, template, ctx, model=XOut)feeds the samectxto both the Pydantic model (JSON) and the template. A key likeis_admin/avatar_url/is_selfholding a non-callable value shadows the global across the whole inheritance chain, turning{% if is_admin(user) %}intoFalse(user)->TypeError, a 500 that fires only for the branch that calls the global. Name viewer/permission flags distinctly (viewer_is_admin, notis_admin) in both schema and context. - Project visibility (
is_private) and read-only (read_only) are owner-controlled flags onprojects. Read access is gated by the singlecontent.can_view_project(project, user)predicate at EVERY read surface - never re-implement the check inline. Predicate:not is_private OR is_owner OR (is_admin AND owner is not an admin)- a project hidden by a member stays visible to any admin, but one hidden by an admin is visible only to that owner admin. Containers have their own, stricter isolation predicates (owns_instance,can_view_project_containers,can_view_instance,can_manage_instance) - the primary administrator sees/manages every container; any other admin can VIEW others' containers only on public projects and can MANAGE only instances they own. Read-only is enforced as a single data-layer guardproject_files._guard_writable(project_uid)at the top of every mutation entrypoint - add it to any NEW file-mutating function. Devii may flip read-only/visibility only after explicit confirmation (CONFIRM_REQUIRED). Full UI-level detail indevplacepy/routers/projects/CLAUDE.md. - Deletions ALWAYS require confirmation:
confirmation_errorgates EVERY content delete tool viaCONFIRM_REQUIRED(delete_post,delete_comment,delete_gist,delete_project,project_delete_file,delete_media,delete_attachment,admin_delete_news, container delete, and anycontainer_execmatchingdispatcher.DESTRUCTIVE_COMMAND). The first call is refused; the agent must show the exact target then passconfirm=true. Load-bearing: every confirmation-gated tool MUST also declare aconfirmboolean param in its catalog spec - schemas setadditionalProperties: false, so a gated tool without a declaredconfirmparam can never receive it and loops forever.
Modal pattern
Application.js initModals() toggles a .visible CSS class on .modal-overlay; the CSS rule .modal-overlay.visible { display: flex; } handles visibility. Triggers usually have href="#", so call e.preventDefault(). .modal-close is wired generically - no inline JS needed. Full modal/partial/CDN detail in devplacepy/templates/CLAUDE.md.
Polymorphic comments and votes
The comments table uses (target_type, target_uid) so _comment_section.html works for post, project, gist, and news. Votes follow the same shape via /votes/{target_type}/{uid}. resolve_target_redirect() in comments.py maps target_type back to the correct detail URL. Full detail (reactions/bookmarks/polls/heatmap/follow/block-mute reuse the same target-type pattern) in devplacepy/routers/CLAUDE.md.
Project-wide soft delete (hard rule)
Every removal is a soft delete; only garbage collection is a hard delete. Removable rows carry deleted_at (ISO timestamp) + deleted_by (actor uid or system); a live row has deleted_at = NULL and every list/count read filters deleted_at IS NULL. The table set is database.SOFT_DELETE_TABLES; init_db ensures both columns + a partial index per table. Core primitives in database/: soft_delete, soft_delete_in, restore, purge, list_deleted/count_deleted, restore_event/purge_event.
- Every INSERT into a soft-deletable table MUST write
deleted_at: None, deleted_by: None-dataset.find(deleted_at=None)on a table missing the column matches NOTHING, silently hiding all rows on a fresh DB. - Any new read of a soft-deletable table MUST filter
deleted_at IS NULL. - Toggles revive, never duplicate: look up the physical row ignoring
deleted_at, stamp on toggle-off, clear on re-toggle. - Cascades share one
stampso the event restores/purges atomically. - Delete authz is owner-OR-admin on the endpoint - one check covers the UI and Devii.
- GC stays HARD (job sweep, metrics ring, usage-ledger prune/reset, expired-session cleanup, fork rollback). Logout is soft (auditable); only expiry GC is hard.
Admin Trash at /admin/trash restores/purges by event. Full table list, dataset rules, indexing conventions (the soft-delete planner trap), and site settings are in devplacepy/database/CLAUDE.md.
Testing
Playwright (NOT pytest-playwright). Around 1959 tests in tests/, split into three category directories by what they exercise: tests/api/ (HTTP integration, no browser), tests/e2e/ (Playwright browser), tests/unit/ (pure in-process). The directory tree mirrors the path - one segment per directory, last segment is the file. api/e2e mirror the URL path (GET /admin/ai-usage -> tests/e2e/admin/aiusage.py); unit mirrors the source module path (devplacepy/utils.py -> tests/unit/utils.py). A test's tier is decided by its fixtures: page/alice/bob = e2e; app_server/seeded_db or any HTTP call = api; local_db-only or no fixture = unit. Run a tier with make test-unit/make test-api/make test-e2e; make test runs all three.
Fixture stack: app_server (session-scoped uvicorn subprocess on port 10501), browser_context (session-scoped Playwright context), page (function-scoped, fresh cookies), alice/bob (seeded logged-in users, bob gets its own context for multi-user tests).
Required patterns: every page.goto(...)/page.wait_for_url(...) MUST pass wait_until="domcontentloaded"; prefer page.locator(...).wait_for(state="visible") over wait_for_selector; scope ambiguous selectors (e.g. comment Delete is .comment-action-btn:has-text('Delete')). A test that flips a global site_settings value MUST restore it in try/finally. Full pitfalls/patterns catalogue is in tests/CLAUDE.md.
Never run tests unless the user explicitly asks for it. Not the full suite, not a single file. Validate with a clean import (python -c "from devplacepy.main import app") and per-language manual checks instead.
Feature workflow
Every feature in DevPlace is one data source fanning out into several consumers. DevPlace has no isolated change - internalize this before editing. A single handler in routers/{area}.py is simultaneously the four faces of one route:
- HTML -
respond()returns a rendered template for browsers. - JSON - the SAME
respond(..., model=XOut)returns JSON whenAccept: application/json. The*Outschema is the gate: a context key not declared on*Outis silently dropped from JSON even though the template still sees it. - Agent tool -
services/devii/actions/catalog.pyexposes the route as anActionso Devii can call it on the user's behalf.requires_authmirrors the route guard. - Documented endpoint -
docs_api.pyendpoint()describes it (params, auth,sample_response) in the right group, rendered at/docs/{group}.html.
A new public read almost always needs all four. The cardinal failure mode is changing one face and forgetting a connected one - a real worked example (followers/following listing) touched nine files across data helpers, output schema, route, view, agent tool, API docs, and the docs trio below for one conceptual feature; that count is the norm, not the exception. If a change touches only one file, confirm none of the four faces were missed. Checklist, ordered by data flow:
- Understand. Read the router, template, matching tests (per the tier naming rule above), and the matching nested
CLAUDE.md. Trace input model -> router -> data helper -> response (HTML and JSON). - Data layer.
database/for query/batch helpers (never inline N+1 loops - useget_users_by_uids,build_pagination,_in_clause; guard raw SQL withif "table" in db.tables).models.pyfor the PydanticForminput model.schemas/for the*OutJSON response model - every context key a JSON route exposes viarespond(..., model=XOut)MUST exist onXOutor it is silently dropped. Schema auto-syncs viadataset; add indexes ininit_db()withCREATE INDEX IF NOT EXISTS. - Server layer. Handler with the right auth guard (
get_current_userpublic read,require_usermember POST,require_adminadmin). Specific paths (/{username}/followers) declared before catch-alls (/{username}). Return HTML+JSON viarespond(request, template, ctx, model=XOut)or pure JSON viaJSONResponse. Register any NEW router inmain.py. - View layer. Extend
base.html; page CSS in{% block extra_head %}, page JS in{% block extra_js %}. Reuse partials and template globals. JS is ES6 modules, one class per file, instantiated onapp. - Agent + docs layer (the most-forgotten step - do not skip).
services/devii/actions/catalog.py- add anActiontool if a user could ask Devii to do this (requires_auth=Falsefor public reads).docs_api.py- every public/auth endpoint gets anendpoint()entry with params and asample_response.routers/docs/pages.pyDOCS_PAGESfor prose pages. SEO (seo.py) -base_seo_contextand any JSON-LD for a new public page; add torouters/seo.pysitemap if indexable. - Document.
README.md(product-facing, routes/config/dependencies/user-visible features), the relevant nestedCLAUDE.md(any new mechanic, helper, pitfall, or cross-layer wiring), this rootCLAUDE.mdonly when a NEW architectural rule or workflow step is introduced (not per feature). - Validate. Confirm a clean import (
python -c "from devplacepy.main import app"), check each touched language manually, grep for em-dash in touched files (neither the character nor its HTML entity - use a hyphen). Never run the test suite unless the user explicitly asks. Write new tests in the matching tier/path when asked, following the required patterns above.
Failures at any implementation step block the workflow - never skip a failed step.
CI/CD
Gitea Actions workflow at .gitea/workflows/test.yaml runs on every push/PR to master: installs dependencies + Playwright Chromium, runs the full suite serially under coverage, publishes coverage HTML as an artifact, uploads failure screenshots. CI must be green before merging. Changes move through DTAP: Development (make dev) -> Test (CI suite + coverage on master) -> Acceptance (master to production promotion via make deploy) -> Production (Docker Compose stack). Only CI-green master commits are promoted to production.
Production deployment
Single-host Docker Compose (docker-compose.yml): an app container (Uvicorn, 2 workers) behind an nginx container. Full operator reference is the admin-only Production docs section (templates/docs/production*.html); conventions that must not regress:
- Shared DB and files = same as dev. The app container bind-mounts the host project root (
.:/app) and runs as${DEVPLACE_UID}:${DEVPLACE_GID}(default1000), so it reads/writes the samedata/devplace.db,data/uploads/,data/devii_*.db,data/keys/(VAPID), anddata/locks/devplace-services.lockasmake dev. NoDEVPLACE_DATABASE_URLoverride -config.pyresolves an absolute path under the project'sdata/dir. WAL + theflockondevplace-services.lockmake concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the same host. - Code updates need no rebuild (bind-mounted source); rebuild only on
pyproject.tomldependency changes..envis git-ignored;.env.exampleis the committed template. The app readsDEVPLACE_DATABASE_URL, neverDATABASE_URL. - nginx parity rules (
nginx/nginx.conf.template, rendered bystart.shviaenvsubstwith an allow-list that preserves$http_upgrade):/static/uploads/must re-applynosniff+ aContent-Dispositionvia themap $uri $upload_dispositionblock (inlinefor safe image/video/audio extensions,attachmentotherwise), mirroringUploadStaticFiles.INLINE_MEDIA_EXTENSIONS- an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production;/devii/wsneeds themap $http_upgrade $connection_upgradeblock andUpgrade/Connectionheaders or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-alllocation /strips upgrade headers);client_max_body_sizecomes fromNGINX_MAX_BODY_SIZE(default50m) and must be>= max_upload_size_mbor uploads 413. nginx servesdevplacepy/staticvia a read-only bind mount, so assets stay current without an image rebuild.