Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
96a2c87
docs: issue-batch-413-424 design
antosubash Oct 9, 2026
6dba0f7
docs: issue-batch-413-424 implementation plan
antosubash Oct 9, 2026
cb6d1ce
fix(hosting): merge every Vary line and blank tenant_source with no t…
antosubash Oct 9, 2026
0213b1e
feat(hosting): publish tenancy mode, single-tenant id and require_ten…
antosubash Oct 9, 2026
044a35e
test(hosting): pin require_tenant commit-time binding (#418)
antosubash Oct 9, 2026
6b11a99
fix(tenants): vary on Cookie when the tenant comes from the session (…
antosubash Oct 9, 2026
3295953
fix(db): keep ORM outer joins outer under the tenant filter (#417)
antosubash Oct 9, 2026
2c7420c
fix(db): never exempt FULL outer joins from the tenant WHERE (#417)
antosubash Oct 9, 2026
64de87c
test(background_tasks): pin timezone-aware timestamps for success_cou…
antosubash Oct 9, 2026
da58ea3
fix(auth): record the post-login target only for navigations (#416)
antosubash Oct 9, 2026
ff52b39
refactor(i18n): split flatten and registry build out of capped files …
antosubash Oct 9, 2026
8875dd9
feat(i18n): host override layer for module copy (#415)
antosubash Oct 9, 2026
0f56061
fix(i18n): doctor reports SM027; overrides reach default-only keys (#…
antosubash Oct 9, 2026
87f8a4c
fix(hosting): emit @source per wheel-module subdirectory (#419)
antosubash Oct 9, 2026
1813c0c
fix(ui): brand foreground ink by WCAG contrast; raise aside footer co…
antosubash Oct 9, 2026
64b1f22
fix(ui): keep the logo badge initial white on the ramp gradient (#420)
antosubash Oct 9, 2026
49199d7
feat(ui): NativeSelect wrapperClassName so callers can size the contr…
antosubash Oct 9, 2026
cd0e6be
fix(admin): page titles, settings h1, admin errors inside the admin s…
antosubash Oct 9, 2026
9f145d6
fix(admin): visible settings heading; ErrorShell out of pages/ (#422)
antosubash Oct 9, 2026
b5a5106
docs: ruff-format plan snippets
antosubash Oct 9, 2026
06dbbcc
docs: changelog for #413–#424
antosubash Oct 9, 2026
86ac7ea
docs: add running-the-stack skill
antosubash Oct 9, 2026
157989a
fix(db): keep relationship-path outer joins outer under the tenant fi…
antosubash Oct 9, 2026
8a5fe64
fix(i18n): doctor loads the hosting catalog before checking overrides…
antosubash Oct 9, 2026
da1b647
fix(db): wire listeners onto every fresh session class (#417)
antosubash Oct 9, 2026
b8be1f0
docs: changelog for the session-listener registration fix
antosubash Oct 9, 2026
8d95a70
refactor(optimize): duplication — fold duplicated test helpers, share…
antosubash Oct 9, 2026
e291718
refactor(optimize): over-engineering — drop redundant guards, args an…
antosubash Oct 9, 2026
a647d58
fix: address code review findings (round 1, pass 1)
antosubash Oct 9, 2026
6961aee
fix(qa): BUG-001, BUG-002 — branding preview ink and colour validation
antosubash Oct 9, 2026
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
42 changes: 42 additions & 0 deletions .claude/skills/running-the-stack/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: running-the-stack
description: Use when launching, restarting, or smoke-testing simple_module_python locally — the FastAPI host API (uvicorn) and the Vite dev server for the Inertia/React client — or when a local run fails to start, a port is taken, or login fails.
---

# Running simple_module_python

## Prerequisites (once per checkout / worktree)
- `uv sync --all-packages && npm install && make gen-pages` — a fresh worktree has no `.venv`, no `node_modules` and no generated pages; skipping this fakes unrelated lint/test failures.
- Postgres/Redis are the SHARED stack in `~/Repos/dev-services` (`make docker-up` starts it). Never start your own containers. Default DB is SQLite, so a plain local run needs neither.
- No `.env` is required. If one exists, read it (don't copy values) — `.env` beats process env for `SM_VITE_DEV_URL`.

## Launch (verified 2026-10-09, SQLite, spare ports 8201/5201)
| Step | Command |
|---|---|
| Check ports free | `ss -ltn \| grep -E ':(8201\|5201) '` (empty = free) |
| DB + migrations | `export SM_DATABASE_URL=sqlite+aiosqlite:///./dev-tmp.db` then `uv run --project host alembic -c host/alembic.ini upgrade heads` |
| Admin login | `uv run smpy users create-admin --email admin@example.com --password <choose-one> --force` |
| Frontend (background) | `SM_VITE_PORT=5201 npm run dev` |
| Backend (background) | `SM_DATABASE_URL=sqlite+aiosqlite:///./dev-tmp.db SM_VITE_DEV_URL=http://localhost:5201 uv run --project host uvicorn host.main:app --port 8201` |
| Everything on default ports | `make dev` — API :8000 + Vite :5050, also runs `docker-up` + `gen-pages` *(unverified this session)* |

## Ready check
- `curl -fsS http://localhost:8201/health` → 200 (about 20 s after start).
- App: `http://localhost:8201/` · sign in at `/users/login` with the admin you created · admin area `/admin`.
- Anonymous `curl` of an app page 302s to the login page; that is expected.

## Stop / reset
- Kill every PID listening on your ports: `ss -ltnp | grep -E ':(8201|5201) '` then `kill <pid> …`. `uv run … uvicorn` spawns a child, and a `--workers N` server leaves workers bound if you only kill one PID.
- `command rm -f dev-tmp.db* < /dev/null`.

## Gotchas
- Ports 8000/5050 are often held by OTHER projects on this machine (a foreign Vite will hydrate this HTML with the wrong bundle). Use spare ports; check `/proc/<pid>/cwd` before killing anything you didn't start.
- Never `pkill -f "uvicorn … --port N"` — the pattern matches your own shell. `make kill` runs `pkill -f vite`, which also kills other projects' Vite servers; prefer killing by port.
- A wrong `E2E_PASSWORD` trips the login rate limiter (5 failures / 300 s) and cascades into 429 timeouts.
- `cp`/`rm` are interactive aliases in this shell: use `command cp -f` / `command rm -f … < /dev/null`.

## Tests
- Unit: `make test-py` (move any `.env` aside first — a dashboard test asserts Vite on :5050) · `make test-js` · single: `uv run pytest path::name`, `npx vitest run <path>`.
- Postgres: `SM_TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/sm_test uv run pytest -p no:anyio <paths>`.
- E2E (server running): `uv run playwright install chromium` once, then `E2E_BASE_URL=http://localhost:8201 E2E_USERNAME=admin@example.com E2E_PASSWORD=<pw> uv run pytest -m e2e tests/e2e -v`. See `docs/e2e-testing.md`.
- Gate before a PR: `make lint` (also format-checks Python in `.md` — run `uv run ruff format docs/` first).
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Added
- **Host override layer for module copy** (#415). A host ships
`host/locales/overrides/<lang>.json` (nested or flat dotted keys) and it is
applied after every module, framework and host catalog, so the server-side
`Translator`, menus and Inertia props all see it. An override only replaces an
existing key: an unknown key is skipped with a warning and reported by
`make doctor`. Admin-only keys stay admin-only.
- **Public tenancy API** (#418): `simple_module_hosting.tenancy` exposes
`tenancy_mode(app)`, `single_tenant_id(app)`, `require_tenant(...)` (binds
the single-tenant id, or the request's tenant else `on_missing`) and
`tenant_vary(request)`; `TenancyMode` lives in `simple_module_core.tenancy`.
Modules no longer need to read the middleware stack.
- `NativeSelect` takes a `wrapperClassName` for sizing the control (#421).
`className` still targets the `<select>`.
- **The `/setup` wizard ships with the framework** (#351). Since 0.0.33
`SetupMiddleware` redirected a fresh install to `/setup`, but the route and
page lived only in this repository's unpublished host, so any other host got
Expand Down Expand Up @@ -83,6 +96,10 @@ All notable changes to this project are documented in this file. The format is b
production-mode containers pass `UsersSettings` boot validation.

### Changed
- `TenantMiddleware` now varies on `Cookie` when the tenant came from the
session, and on `Cookie, Authorization` when it came from an authentication
claim, so a shared cache can no longer serve one tenant's response to another
(#418).
- **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
Expand Down Expand Up @@ -139,6 +156,42 @@ All notable changes to this project are documented in this file. The format is b
`tenants` resolver it selects among the user's own memberships.

### Fixed
- `/admin/background-tasks` returned 500 on Postgres (#413): `TaskExecution`'s
timestamps were declared naive although the columns are `timestamptz` since
`e5f2a8c1d7b3`. This was already fixed on `main` by #406 (unreleased since
v0.0.35); this branch only pins it with tests. No migration.
- Sign-in aside footer text met only 2.7:1 contrast; `--color-dark-text-subtle`
is lighter and clears WCAG AA (#414).
- Only top-level navigations record the post-login target (#416): a favicon,
script, image or `fetch()` hitting an unauthenticated route no longer
overwrites it. Navigations and Inertia visits record the target as before;
requests that carry no fetch metadata keep the old behaviour.
- The tenant filter no longer turns an outer join into an inner join when the
joined table is only referenced inside a function such as
`func.count(Child.id)` (#417), whether it joins the entity or a relationship
path (`.outerjoin(Parent.children)`, with or without `of_type`). A raw-table
target or a `secondary` relationship stays filtered in `WHERE`, so this never
leaks another tenant's rows.
- `attach_session_listeners` (simple_module_db) now marks each session class it
wires instead of asking SQLAlchemy's `event.contains`, which keys on `id()`.
A new session class that reused the id of a garbage-collected one was treated
as already wired and got no tenant filter, soft-delete filter or write
markers, so tenant isolation failed open. A single-app worker process was not
affected; any process that builds more than one app or DatabaseState and
drops an earlier one was (the test suite, scripts, embedders).
- `gen-pages` emits `@source` lines for subdirectories of wheel modules (#419);
uv's `.venv/.gitignore` made Tailwind skip them, so their utility classes
were missing from the built CSS.
- Brand foreground ink follows the brand colour (#420): `deriveBrandRamp` sets
`--primary-foreground` and `--sidebar-primary-foreground` by WCAG contrast
(white or dark ink), so buttons and the active sidebar row stay legible on a
light brand colour; the logo badge initial stays white on the ramp gradient.
- Admin screens (#422): `/admin/users/` has a document title, `/admin/settings/`
has a visible `<h1>`, and error pages under `/admin` for a signed-in admin
render inside the admin shell.
- `TenantMiddleware` merges every `Vary` response line and leaves a `Vary: *`
response untouched (#423), and `tenant_source` is `None` whenever no tenant is
bound (#424).
- 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
template ships `<div id="app"></div>` empty — so a navigation that creates a
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ 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). `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). `SM026` the database is SQLite and a model declares an expression index it cannot verify (info — autogenerate and `alembic check` skip those on SQLite; add them by hand and run `make migrations-roundtrip-pg`). 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). `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). `SM026` the database is SQLite and a model declares an expression index it cannot verify (info — autogenerate and `alembic check` skip those on SQLite; add them by hand and run `make migrations-roundtrip-pg`). `SM027` a host locale override (`host/locales/overrides/<locale>.json`) names a key no catalog defines (warn — skipped; reported by `make doctor` and at dev boot). In production, errors fail boot.

## Tests & fixtures

Expand Down
17 changes: 17 additions & 0 deletions docs/framework/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ Module-scope `t(...)` calls capture the first-render locale and never update.

Both are auto-discovered alongside module contributions — no manual wiring.

### Overriding module copy

A host cannot reach a module's keys through `host.*`. To change copy a module ships (for
example the sign-in aside), add `host/locales/overrides/<lang>.json`, keyed by the **full
dotted path** — nested or flat both work:

```json
{ "users.login.aside_heading": "Kuri for teams" }
```

Overrides are applied after every module, framework and host catalog, per locale. An
override only **replaces an existing key**: a key no catalog defines is skipped, logged, and
reported as `SM027`, so a typo cannot silently invent copy nothing renders. Audience is
preserved: overriding a key from an admin-only catalog does not expose it to anonymous
visitors. The overrides directory is not part of the `host` namespace and does not change the
generated TypeScript key list.

## Configuration

The three i18n fields are declared on `HostSettings`, but they are **read from the environment at boot**:
Expand Down
56 changes: 56 additions & 0 deletions docs/framework/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,44 @@ Most auth providers set no `tenant_id` claim, so `multi_tenant` with no
resolver fails every tenant-scoped query closed; the boot reports that as
`SM025`.

## For module authors

`simple_module_hosting.tenancy` answers the questions a module used to answer by
reading the middleware stack or hardcoding `"default"` (#418):

| Name | What it gives you |
|---|---|
| `TenancyMode` | `SINGLE` (one tenant for every request: no `TenantMiddleware`, or one pinned by `default_tenant`) or `MULTI` (`multi_tenant` on, each request resolves its own). |
| `tenancy_mode(app)` | The host's mode, recorded on `app.state.sm.tenancy` where `create_app` decides on `TenantMiddleware`. |
| `single_tenant_id(app)` | The tenant a single-tenant host runs in: `default_tenant`, else `DEFAULT_TENANT_ID`. Use it instead of a literal `"default"`. |
| `require_tenant(*, on_missing=403, detail="tenant_required")` | A dependency that binds the request's tenant and yields it. |
| `tenant_vary(request)` | The request headers the resolved tenant depended on, for a route that sets its own cache headers. |

Put `require_tenant()` on every router that reads or writes `MultiTenantMixin`
tables. On a single-tenant host it binds `single_tenant_id(app)`, so the route
works whether or not `TenantMiddleware` is installed; on a multi-tenant host it
binds what the middleware resolved and refuses the request when nothing was:

```python
from fastapi import APIRouter, Depends, HTTPException
from simple_module_hosting.tenancy import require_tenant

# Admin surface: no tenant is a 403 {"detail": "tenant_required"}.
admin = APIRouter(dependencies=[Depends(require_tenant())])

# Public surface: a page that does not exist for this visitor is a 404.
public = APIRouter(dependencies=[Depends(require_tenant(on_missing=lambda _r: HTTPException(404)))])
```

`on_missing` is a status code, or a callable taking the request and returning
the exception to raise.

**Order matters.** List `require_tenant()` *before* `get_db`. Yield
dependencies exit in reverse order, and the session's commit has to run while
the tenant is still bound. A router-level dependency, as above, always runs
before the route's own parameters, so `db: RequestSession = Depends(get_db)` on
the endpoint is safe.

## Tenant roles

A membership role — `owner`, `admin` or `member` — reaches the request
Expand Down Expand Up @@ -308,6 +346,24 @@ alone). The `tenants` resolver reports `Host` when subdomains are enabled and
the tenant header whenever it is configured, even if the answer was `None`, so
a shared cache cannot serve one tenant's response to another.

The rules for the merged header:

- Every `Vary` line already on the response is merged into one, and a `*` in
any of them is never dropped.
- `tenant_source` is `None` whenever no tenant is bound, whatever the resolver
reported as its source.
- A tenant taken from the principal's `tenant_id` claim varies on
`Cookie, Authorization`: the claim came from whichever credential
authenticated the request.
- The `tenants` resolver adds `Cookie` whenever a user is signed in, on every
branch — subdomain, header and session, resolved or not — since membership
is checked against the signed-in user (and the session cookie also carries
the stored choice). An anonymous request does not vary on `Cookie`, so an
anonymous public page stays cacheable.

A route that builds its own cache headers reads the same list with
`tenant_vary(request)` (see [For module authors](#for-module-authors)).

## Unique keys

On a tenant-scoped table every business key is per tenant: put `tenant_id` in
Expand Down
1 change: 1 addition & 0 deletions docs/reference/diagnostic-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The framework runs a set of static checks over installed modules at app boot. Th
| `SM024` | WARNING | A unique column, constraint or index on a `MultiTenantMixin` table does not include `tenant_id`, so the first tenant to claim a value locks every other tenant out of it. | Make the key per tenant: add `tenant_id` to it (`Index(..., "tenant_id", "slug", unique=True)`). |
| `SM025` | WARNING | `multi_tenant` is on but no module registered `app.state.tenant_resolver`, so only the principal's `tenant_id` claim can bind a tenant — with most auth providers every tenant-scoped query then fails closed. Checked at boot (it needs the built app), in every environment, not by `make doctor`. | Install the `tenants` module, or register your own `async (Request) -> str \| None` resolver on `app.state.tenant_resolver`. |
| `SM026` | INFO | The configured database is SQLite and a module model declares an expression index (e.g. `lower(email)`). SQLite cannot reflect those, so autogenerate and `alembic check` cannot verify them and a clean diff says nothing about them. | Add the index to a revision by hand and verify with `make migrations-roundtrip-pg` (PostgreSQL). See [Migrations](/module-authoring#reviewing-a-revision-autogenerated-on-sqlite). |
| `SM027` | WARNING | A key in `host/locales/overrides/<locale>.json` matches no catalog key, so the override was skipped. Reported by `make doctor` and at dev boot. | Fix the key: overrides replace an existing key by its full dotted path (e.g. `users.login.aside_heading`). See [i18n](/framework/i18n#overriding-module-copy). |

`SM022`/`SM023` are the two halves of the same invariant: a module's optional [`theme.css` is imported unlayered and `styles.css` into `layer(components)`](/module-authoring#styling), and CSS put in the wrong one silently does nothing (or silently wins everything).

Expand Down
Loading
Loading