Skip to content

Latest commit

 

History

History
149 lines (107 loc) · 23.8 KB

File metadata and controls

149 lines (107 loc) · 23.8 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Stack

Python 3.12 + FastAPI + SQLModel + Alembic on the backend; Inertia.js + React + Tailwind 4 + Vite on the frontend. uv workspaces for Python, npm workspaces for JS. Package managers: uv sync --all-packages and npm install.

Local database & services (shared dev-services stack)

Do NOT start your own Postgres/Redis container. All repos in ~/Repos share one stack defined in ~/Repos/dev-services (one PostGIS + Redis + MinIO + Adminer on the devnet Docker network). make docker-up here brings that shared stack up (it no longer starts a local postgres/redis); make docker-down only stops this repo's own containers (worker/beat), never the shared stack.

This repo's slice:

  • Postgres: localhost:5432, postgres/postgres, database simple_module_python (the default SM_DATABASE_URL is SQLite; uncomment the Postgres line in .env.example to use the shared DB).
  • Redis: localhost:6379, logical DBs 4 (broker) / 5 (result backend).

If a port is taken by an old per-project container, stop that container rather than remapping ports. To add a database, edit dev-services/init/01-databases.sql.

Commands

All day-to-day tasks go through make:

Command Purpose
make install Install Python + JS deps
make dev Docker up + regen module pages + API (8000) and Vite (5050) in parallel
make kill Free ports 8000/5050/5173
make test Run test-py then test-js (e2e excluded by default)
make test-py / make test-js Run a single suite
make test-py-pg Python suite on Postgres (SM_TEST_PG_URL, default db sm_test, schema is dropped per test)
make test-e2e Playwright smoke tests (requires make dev running + uv run playwright install chromium)
make lint Ruff format-check + Ruff + ty + Biome + per-workspace tsc + 300-line file cap
make doctor Module diagnostics (orphan pages, coupling violations, migration drift, locale checks) — same checks run at prod boot
make migrate / make migration msg="..." Apply / autogenerate Alembic migrations
make new-module name=<name> Scaffold a new module package end-to-end
make gen-pages Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} from installed modules

Single test: uv run pytest path/to/test_file.py::test_name (root pyproject.toml sets asyncio_mode=auto and -m 'not e2e and not perf'). A single JS test: npx vitest run <path>.

Entry point: host/main.py (uv run --project host uvicorn host.main:app --reload). Alembic runs from the repo root (host/alembic.ini) so it shares the .env / SM_DATABASE_URL with the API.

Architecture

This is a modular-monolith framework. There is no host–module API boundary: modules are Python packages loaded into one FastAPI app.

Discovery. Modules declare [project.entry-points.simple_module] in their pyproject.toml pointing at a ModuleBase subclass. simple_module_core.discovery.discover_modules() loads them, topologically sorts by ModuleMeta.depends_on, and the host invokes register_* hooks in that order. In production (SM_ENVIRONMENT != development) discovery is strict — any entry-point failure or missing/invalid meta raises at boot.

Module layout (scaffolded by make new-module):

modules/<name>/<name>/
├── module.py        # ModuleBase subclass with meta = ModuleMeta(...)
├── models.py        # SQLModel tables
├── contracts/       # SQLModel DTOs (public surface) — add a Protocol only for real extension points
├── service.py       # business logic
├── deps.py          # FastAPI dependencies
├── endpoints/api.py # REST (JSON)
├── endpoints/views.py # Inertia view endpoints
├── pages/*.tsx      # auto-discovered by Vite via modules.generated.ts
├── theme.css        # optional — @theme tokens; imported UNLAYERED
├── styles.css       # optional — component rules; imported into layer(components)
└── locales/<lang>.json

Both CSS files are optional and auto-detected; gen-pages emits an @import "#module/<pkg>/..." for each, so nothing is added to the host's styles.css by hand. The split is load-bearing: a @theme block inside a cascade layer is inert, while unlayered CSS beats every Tailwind utility — hence SM022/SM023. See docs/module-authoring.md § Styling.

Lifecycle hooks (in framework/core/simple_module_core/module.py) — all no-op by default; subclasses override as needed: register_settings → register_menu_items / register_permissions / register_feature_flags / register_event_handlers / register_invalidations / register_health_checks / register_public_routes / register_csp_sources / register_setup_steps → register_exception_handlers → register_middleware → register_routes(api_router, view_router) / register_admin_routes(admin_router) → async on_startup / on_shutdown (reverse order). register_admin_routes is only for modules that serve both public and admin pages: a module gets exactly one router per view_prefix, which users cannot express (sign-in at /users/login, management at /admin/users). Setting ModuleMeta.admin_view_prefix mounts a second view router there. A module whose views are all administrative just points view_prefix at /admin/<name> and keeps using register_routes. The prefix is a URL convention, not a permission — guard these routes exactly as you would any other. register_csp_sources(registry) lets a module whitelist external asset origins (registry.add("style-src", "https://rsms.me")) — fetch directives only, validated at boot. register_public_routes(registry) lets a module exempt anonymous/read-only routes (STAC/OGC, webhooks) from AuthMiddleware; rules are method-aware (registry.add_regex(r"…/tilejson$", methods={"GET"})), so a GET read route can be public while sibling POST/PATCH mutations under the same prefix stay gated. See docs/framework/public-routes.md. register_setup_steps(registry) lets a module declare what a usable install still needs; while any required step is incomplete SetupMiddleware serves the first-run wizard at /setup instead of the app. A module that registers nothing never gates — that is how keycloak opts out, since its local users table is legitimately empty forever and a host-level superuser count would lock those installs out permanently. register_invalidations(bus, app) subscribes a module's per-process caches to InvalidationBus, so another worker's write drops this worker's entry instead of leaving it stale for its whole TTL; handlers may only forget, since there is no delivery guarantee. Publishing takes no hook — await request.app.state.sm.invalidation.publish(channel, key=...) from a db.on_commit callback. Cross-process delivery needs a transport, which background_tasks installs on its Redis connection (SM_BG_TASKS_BROADCAST_INVALIDATIONS); with none the bus is in-process and every cache still needs its TTL as a floor. See docs/framework/invalidation.md.

Middleware pipeline (Starlette add_middleware is LIFO — last added runs first). Execution order on a request: (ProxyHeaders, if SM_TRUSTED_PROXY) → CorrelationId → RequestLogging → GZip → SecurityHeaders → Session → <module middleware> → Tenant (opt-in) → Locale → InertiaLayoutData → InertiaCache → Setup → Maintenance → CommitBeforeResponse → app. InertiaCache answers for InertiaLayoutData merging per-user auth/menus into every payload: a response to an X-Inertia request is forced to private, no-store with its ETag dropped, and both representations of a URL gain Vary: X-Inertia — so no cache can store the JSON payload or hand it back for a page request. A module wanting its public page content cached should set Cache-Control and an ETag on the document; that path is left alone. GZip compresses any response over 500 bytes, including the /static mount — the built CSS is ~139 KB raw versus ~21 KB gzipped, and uncompressed assets dominated cold page load. ProxyHeaders (uvicorn's ProxyHeadersMiddleware) is installed only when SM_TRUSTED_PROXY is set, sitting outermost so the X-Forwarded-*-corrected scheme/client IP reach everything downstream (request logs). Inertia does not depend on it: the page url is rewritten to the root-relative form the protocol specifies (_inertia_url.py), so no scheme travels in the payload to disagree with the document's — the cross-scheme pushState SecurityError of GH #223 cannot recur on an install that never set the variable. When two modules add middleware at the same dependency tier, the module that sorts later wraps outermost. Use depends_on to express relative order — don't rely on names. Maintenance serves a 503 page to everyone but admins while maintenance_mode is set on HostSettings; it sits inside InertiaCache because its 503 is an Inertia payload produced by short-circuiting, and outside the cache guard that payload would ship storable. Setup runs just before it, for the same cache reason and because an install that was never set up has nothing meaningful to put into maintenance.

Database: per-module Base via create_module_base("<name>"). On SQLite init_db enables WAL, an explicit busy_timeout, and foreign_keys=ON — so both sides of an FK must use the same column type (sa.Uuid and fastapi-users' GUID are identical on Postgres but not on SQLite), and parents must be flushed before children. Every module owns its own MetaData (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. __tablename__ must be prefixed with the module name to avoid collisions (orders_order). Postgres and SQLite share the same layout.

Standard mixins in simple_module_db.mixins: AuditMixin, SoftDeleteMixin (bypass the read filter with stmt.execution_options(include_deleted=True); purge by deleting an already-trashed row, or with hard_delete(session, obj)), MultiTenantMixin, VersionedMixin. The soft-delete/tenant filters cover every statement shape, not only selects that name the entity — joins, ORM subqueries, select(func.count()).select_from(Model), and Core statements over Model.__table__ (GH #332). Tenancy fails closed: with multi_tenant on, a query or insert on a MultiTenantMixin model with no current_tenant_id raises TenantIsolationError instead of reading every tenant; cross-tenant code says so with all_tenants() / execution_options(all_tenants=True), and jobs/CLI act for one tenant with tenant_context(id). With multi_tenant off and no tenant bound, inserts are stamped DEFAULT_TENANT_ID ("default", from simple_module_db) and reads stay unfiltered; adoption migrations backfill with the same constant. Tenant roles reach the principal as tenant:<role> — map them with tenant_role(TenantRole.MEMBER) from simple_module_core.tenancy, never by importing tenants; tests use the tenant_client(role) fixture. Unique keys on such tables must include tenant_id (SM024). The tenants module owns organisations, memberships and app.state.tenant_resolver; tenant-level routes act on the active tenant, never a tenant id from the URL. See docs/framework/multi-tenancy.md. The per-request session (get_db) auto-commits only if there are pending writes (via after_flush listener); otherwise rollback. Service code should not call session.commit() — flush if you need DB-assigned values. DML executed through the session (session.execute(update(Model)...)) counts as a write; a raw text("UPDATE ...") does not, and needs mark_written(session). The commit fires in CommitBeforeResponseMiddleware, at the ASGI http.response.start message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a yield dependency's exit code after the response is delivered, which used to make that a deterministic 404 (GH #257). get_db keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins.

Migrations live in host/migrations/versions/ — not in module packages. host/alembic/env.py calls build_module_metadata() + make_include_object() so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set branch_labels = ("<module_name>",) to enable per-module downgrade <module>@base.

Admin section. Administrative screens live under /admin/*, register into MenuSection.ADMIN_SIDEBAR, and render in AdminLayout — all three together, not one of the three. SidebarLayout renders whichever menu its menuKey names, so a page left on AuthenticatedLayout after its menu item moved shows a sidebar that no longer contains it. group= sub-clusters within the admin sidebar (Access, Appearance, System); it is no longer used to carve an admin area out of the main sidebar. /admin itself is a host route (host/routes.py) that renders from the adminSidebar shared prop, so an installed module contributes a card without touching it. Old URLs 301 from host/routes_legacy.py. Only view URLs moved — /api/* is a separate contract and stays put.

Inertia. inertia.render("<ModuleName>/<PageName>", ...) maps to modules/<name>/<name>/pages/<PageName>.tsx, where <ModuleName> is the PascalCase of the module directory (blog_posts → BlogPosts). Host-level pages under host/client_app/pages/ use a bare <PageName>. InertiaLayoutDataMiddleware populates shared props (auth, menus, i18n); use InertiaDep from simple_module_hosting.inertia_deps. Mismatched keys fire SM003 (orphan page) / SM004 (phantom render). The adapter is the in-repo package simple_module_inertia (Inertia v3 protocol); import InertiaResponse and the prop factories (optional, always, defer, merge, once, scroll) from it.

CSRF defence. Baseline protection comes from SameSite=Lax on the session cookie (Starlette default): browsers don't attach the cookie to cross-site POST/PUT/DELETE, so a forged form-submit from another origin is unauthenticated. Raw fetch() calls in page code don't need a token header by default. Modules wanting defence in depth opt into simple_module_hosting.csrf — RequiresCsrf as a router dependency plus get_csrf_token(request) exposed as a view prop; callers echo it as X-CSRF-Token on unsafe methods.

Conventions to follow

  • SQLModel is the project-wide standard for every model — both DB tables (table=True) and DTOs (plain SQLModel subclasses). Do not use Pydantic BaseModel or SQLAlchemy DeclarativeBase + Mapped[...] in module code.
  • 300-line cap on .py/.ts/.tsx files, enforced by scripts/check_file_size.py in CI (exempts vendored shadcn components under packages/ui/src/components/ui/**). If you approach the cap, split by responsibility — don't rewrite to squeeze under.
  • Per-module settings: env-var prefix SM_<MODULE>_*, stored on app.state.<module_lower> inside register_settings(app) as a module-owned dataclass. SM012 warns if register_settings is overridden without adding app.state.<module_lower>.
  • Configuration lives in the DB, not .env. Only SM_DATABASE_URL is required; SM_REDIS_URL and the optional SM_USERS_BOOTSTRAP_* are the only others an operator normally sets. SM_ENVIRONMENT/SM_DEBUG/SM_VITE_DEV_URL describe the process rather than the configuration. Precedence is always env → DB → default; env must keep winning or existing deployments change behaviour silently on upgrade.
  • Settings are read twice per boot. create_app builds the module list, the i18n registry and the middleware stack in Phases 1 and 8 — all before the lifespan opens the DB — so _preapp_config.merge_host_settings does one short-lived read before Phase 1. The lifespan then hydrates again for runtime edits. A HostSettings field consumed at build time is inert without that first read: this is why i18n_* and multi_tenant edits in the admin UI used to do nothing. host/main.py must call merge_host_settings(), not Settings() — it passes settings into create_app, so create_app's own fallback never fires there.
  • SM_SECRET_KEY is optional: unset generates a key and stores it (atomically — concurrent workers must converge, or they invalidate each other's sessions). An explicit env value wins and is never copied into the DB.
  • Framework vs plugin coupling: SM009 is an error if framework/* directly imports from a plugin module. Framework code must not reach into modules/.
  • Zod schemas with translated messages must be constructed inside a hook (useT()) — never at module scope, or they freeze against the first render's locale.
  • Locales: ship <package>/locales/<lang>.json and declare in ModuleBase.locale_dirs() with the module's lowercase name as the namespace. {"browse": {"title": "X"}} flattens to <namespace>.browse.title. Pluralize with CLDR suffixes (_one, _other, ...).
  • No user-visible string literals in .tsx — enforced by make ci-check-untranslated, see § CI. Every rendered string — JSX text, placeholder, aria-label, <Head title>, toast.*(), confirm text — goes through t(keys.<namespace>.…) from @simple-module-py/i18n. Exempt: shell commands, env-var names, and JSON examples shown as literal <code>. In non-component modules (a retry.ts helper) import the non-hook t and call it inside the function, never at module scope, or it freezes against the boot locale.
  • Menu labels: set label_key/group_key on MenuItem next to label/group; group headers use the shared ui.nav_groups.* keys. Menus are translated server-side in MenuRegistry.get_for_user(translate=…), and an unresolved key falls back to the literal label. See docs/framework-conventions.md § Shared props.
  • Regenerate packages/i18n/src/{keys.generated,generated-resources}.ts after touching any catalog — booting the host in development does it, and t() only accepts keys present there.
  • Ty (type checker) false positives from SQLModel: unresolved-attribute, unsupported-operator, unknown-argument, no-matching-overload, invalid-argument-type are all globally ignored in pyproject.toml because SQLModel declares fields with plain Python types while runtime instruments them as SQLAlchemy attributes. Do not re-enable these rules — real bugs surface in tests.

Diagnostic codes

Meaningful codes when reading make doctor output: SM001 missing meta (error), SM003 orphan page / SM004 phantom render (warn), SM007 module overrides no hooks (info), SM008 duplicate name (error), SM009 framework→plugin import (error), SM010 DB revision behind head (error), SM011 module table not in migration history (warn), SM012 register_settings overridden but nothing on app.state.<module> (warn, fires at dev boot only), SM013–SM016 locale issues, SM017 module ships .tsx pages but is missing package.json/tsconfig.json (warn), SM018 Inertia router.{post,patch,put,delete}() in a page targets a JSON /api/* endpoint (warn — Inertia rejects non-Inertia responses), SM019 module registers view routes (non-empty view_prefix + overrides register_routes) but overrides neither register_menu_items nor register_permissions (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. SM020 multiple auth provider modules installed (error), SM021 no auth provider module installed (warn), SM022 @theme/@custom-variant/@utility in a module's styles.css, where layer(components) makes them inert (warn), SM023 an unlayered rule in a module's theme.css, which outranks every Tailwind utility (warn). SM024 a unique key on a MultiTenantMixin table that omits tenant_id (warn). SM025 multi_tenant is on but no module registered app.state.tenant_resolver (warn — checked at boot after module registration, in every environment, not by the make doctor CLI). In production, errors fail boot.

Tests & fixtures

The simple_module_test plugin provides app-level fixtures available to every test directory — auto-loaded via its pytest11 entry point (defined in framework/testing/simple_module_test/fixtures.py), so the root conftest.py is intentionally thin:

  • settings — in-memory SQLite Settings with multi_tenant=True. Set SM_TEST_DATABASE_URL=postgresql+asyncpg://… to run the fixtures (and the tenancy DB tests) on Postgres instead; each test then starts from an empty public schema, reset once per test so app and db_session share it (simple_module_test.database). make test-py-pg runs the whole suite that way, with -p no:anyio: an @pytest.mark.anyio test would run on a different event loop from its async fixtures, and an asyncpg connection cannot cross loops.
  • db_state, engine, db_session — fresh in-memory DatabaseState per test; db_session also creates all module tables and stamps alembic_version at head so the boot-time migration check passes.
  • app — create_app(settings) with lifespan started/stopped.
  • client / authenticated_client — httpx.AsyncClient; authenticated_client seeds an admin via users.bootstrap.create_admin and carries a forged session cookie.

E2E tests live in tests/e2e/ behind the e2e pytest marker and run against a live server — see docs/e2e-testing.md.

CI

.github/workflows/pr.yml runs Python lint / typecheck / tests, JS lint / typecheck / tests, the 300-line file-size check, and the untranslated-string check as parallel jobs; make lint locally runs the same checks serially. Branch protection requires the aggregate pr-checks job.

make ci-check-untranslated (scripts/check_untranslated_strings.mjs) parses every .tsx and fails on user-visible text rendered as a literal — JSX text, a title/placeholder/aria-label-style attribute, or a toast.*()/confirm() argument — including copy hidden in cond ? 'A' : 'B'. It parses with @babel/parser rather than grepping, because no regex over JSX can tell <p>Save</p> from Promise<void>. It does not see strings passed through a variable or a config object (const THEME = { mobileTitleLabel: 'Admin' }), so those still need care.

To exempt a genuinely technical literal: wrap it in <code>/<pre>, or mark the line // i18n-exempt: <reason>; i18n-exempt-file: <reason> in a file's first lines skips the whole file.

Delegating to subagents

Pick the subagent's model for the task, not the most capable one available. Don't run everything on opus or fable:

  • haiku: search, file discovery, and mechanical work like renames, checking docs against code, or collecting test output.
  • sonnet: routine implementation, functional and end-to-end testing, and regression runs.
  • opus: design, security and isolation reasoning, and adversarial review, where a wrong answer is expensive.

Pass model explicitly on every Agent call, even when the default would be correct.

Authoritative references

When conventions are unclear, these docs are the source of truth (don't reverse-engineer the code):