Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ jobs:
- simple_module_permissions
- simple_module_settings
- simple_module_site_lock
- simple_module_tenants
- simple_module_users
environment:
name: pypi
Expand Down
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Added
- **Postgres test runs** (#343) — `SM_TEST_DATABASE_URL` points the
`simple_module_test` fixtures at Postgres, and `make test-py-pg` runs the
whole Python suite there. The schema is reset once per test, so `app` and
`db_session` see each other's rows as they would in production.
- **`tenants` module** — SaaS organisations: tenants, many-to-many memberships
with per-tenant roles (`owner`/`admin`/`member`, surfaced as `tenant:<role>`
on the active tenant only), email-bound invitations, platform suspend /
reactivate, and the membership-validated tenant resolver. Ships the seams a
billing module needs: an `EntitlementProvider` on
`app.state.tenants.entitlements` (seat limits enforced, HTTP 402), lifecycle
via `TenantService.set_status`, and after-commit domain events. See
[docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md).
- `simple_module_db.tenant_context()` / `all_tenants()` and the
`all_tenants=True` execution option, for acting as one tenant — or
deliberately across tenants — outside a request.
- `TenantMiddleware` consults `app.state.tenant_resolver` when a module
registers one.
- `background_tasks` carries the enqueuing request's tenant into the Celery
task and restores it around the task body.
- Doctor check `SM024`: a unique key on a `MultiTenantMixin` table that omits
`tenant_id`.

- `InvalidationBus` — a framework-level cache-invalidation channel any module can
publish on (`ModuleBase.register_invalidations`, `app.state.sm.invalidation`).
In-process by default; `background_tasks` installs a Redis pub/sub transport on
Expand Down Expand Up @@ -45,6 +67,45 @@ All notable changes to this project are documented in this file. The format is b
generates real `SM_USERS_*_TOKEN_SECRET` values into `.env.example` so the
production-mode containers pass `UsersSettings` boot validation.

### Changed
- **Tenant isolation fails closed.** With `multi_tenant` on, a query, bulk
`update()`/`delete()` or insert on a `MultiTenantMixin` model with no tenant
context raises `TenantIsolationError` instead of reading or writing every
tenant's rows. ORM `update()`/`delete()` are now tenant-scoped too; they were
not before.
- Changing a row's `tenant_id` is refused whether or not a tenant is bound
(it used to be checked only inside a tenant context); only an `all_tenants()`
block may move a row between tenants.
- Tenant rules now cover every ORM write path, not only `session.add`: an
ORM `insert(Model)` (bulk or `.values()`) is stamped with the bound tenant
and refused for a different one (#357); `update(Model).values(tenant_id=…)`
is refused; a flush that writes or deletes an object belonging to another
tenant (e.g. one returned from the identity map after a `tenant_context`
switch) is refused.
- `tenant_context()` nested in `all_tenants()` now scopes its block; it used
to be ignored there, so a per-tenant loop inside a platform job ran
unscoped.
- Strict mode is held per engine, so a second `DatabaseState` in the process
no longer switches it off for the first. The Celery worker's session gets the
tenant listeners and the host's `multi_tenant` setting too (#371).
- New `MissingTenantError` (a `TenantIsolationError`) for "no tenant bound".
- Tenant and soft-delete criteria reach join targets, subqueries (including a
bare Core `exists().where(...)`), `count().select_from()` and top-level Core
statements on `Model.__table__` (#332). **Behaviour change:** a join or count
over a soft-deletable model now excludes trashed rows, as a plain `select`
already did; `include_deleted=True` still reveals them.
- `HostSettings.default_tenant`: single-tenant hosts run mixin tables as one
tenant (#359). `bind_current_tenant(fn)` carries the tenant into work a
module defers past the request (#364). The `tenants` module resolves a
tenant from the subdomain (`subdomain_base`), anonymous visitors included
(#363).

### Security
- The tenant header (`tenant_header`) is no longer honoured for an
authenticated user without a tenant of their own: such a user could name any
tenant. On the legacy path it applies to anonymous requests only; with the
`tenants` resolver it selects among the user's own memberships.

### Fixed
- Public pages no longer reload the whole document when a visitor clicks a link
in authored content. A simple_module app is client-rendered — the root
Expand Down
17 changes: 14 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ All day-to-day tasks go through `make`:
| `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 |
Expand Down Expand Up @@ -81,7 +82,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling.

**Database**: per-module `Base` via `create_module_base("<name>")`. 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 with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. 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. 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.
Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. **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)`. 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](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. 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`.

Expand Down Expand Up @@ -109,12 +110,12 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b

## 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). In production, errors fail boot.
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). 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`.
- `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.
Expand All @@ -129,6 +130,16 @@ E2E tests live in `tests/e2e/` behind the `e2e` pytest marker and run against a

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):
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker

# Install
install:
Expand Down Expand Up @@ -48,6 +48,16 @@ test: test-py test-js
test-py:
uv run pytest

# The Python suite on Postgres (#343). Needs an empty database the tests may
# drop and recreate `public` in. `-p no:anyio`: tests marked
# `@pytest.mark.anyio` would otherwise run on anyio's event loop while the
# async fixtures ran on pytest-asyncio's, and an asyncpg connection cannot
# cross loops (aiosqlite's thread hides this on SQLite). asyncio_mode=auto
# still runs those tests.
SM_TEST_PG_URL ?= postgresql+asyncpg://postgres:postgres@localhost:5432/sm_test
test-py-pg:
SM_TEST_DATABASE_URL=$(SM_TEST_PG_URL) uv run pytest -p no:anyio

test-js:
npm test

Expand Down
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export default defineConfig({
{ text: "Public routes", link: "/framework/public-routes" },
{ text: "Events", link: "/framework/events" },
{ text: "Cache invalidation", link: "/framework/invalidation" },
{ text: "Multi-tenancy", link: "/framework/multi-tenancy" },
{ text: "Internationalization", link: "/framework/i18n" },
],
},
Expand Down Expand Up @@ -185,6 +186,7 @@ export default defineConfig({
{ text: "background_tasks", link: "/modules/background_tasks" },
{ text: "audit_log", link: "/modules/audit_log" },
{ text: "site_lock", link: "/modules/site_lock" },
{ text: "tenants", link: "/modules/tenants" },
{ text: "dashboard", link: "/modules/dashboard" },
],
},
Expand Down
4 changes: 2 additions & 2 deletions docs/framework-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,8 @@ Base = create_module_base("orders")
### Mixins

- `AuditMixin` — `created_at`, `updated_at`, `created_by`, `updated_by` (auto-populated from the current user in listeners).
- `SoftDeleteMixin` — `is_deleted`, `deleted_at`, `deleted_by`. `delete()` converts to soft-delete; `SELECT` auto-filters. Bypass with `stmt.execution_options(include_deleted=True)`.
- `MultiTenantMixin` — `tenant_id`. Auto-populated on insert; `SELECT` auto-filters when `current_tenant_id` is set.
- `SoftDeleteMixin` — `is_deleted`, `deleted_at`, `deleted_by`. `delete()` converts to soft-delete; reads auto-filter trashed rows wherever the table appears — joins, subqueries, counts, top-level Core statements (#332). Bypass with `stmt.execution_options(include_deleted=True)`.
- `MultiTenantMixin` — `tenant_id`. Auto-populated on insert; `SELECT`, ORM `UPDATE` and `DELETE` are scoped to `current_tenant_id`. With `multi_tenant` on, a query with **no** tenant raises `TenantIsolationError` (fail closed) — cross-tenant code opts out with `all_tenants()` / `execution_options(all_tenants=True)`. See [multi-tenancy](/framework/multi-tenancy).
- `VersionedMixin` — `version`, auto-incremented on update.

### Session lifecycle (`get_db`)
Expand Down
Loading
Loading