Repository navigation
v0.0.38
Fixed
- Users (security): disabling or enabling an account, or replacing its roles from the admin
screens, now bumpssession_versionin the same transaction and evicts the cached counter on
every worker after commit (an atomic SQL increment, so overlapping changes cannot lose one), so
existing cookie sessions and bearer access tokens are refused on their next request; disabling
also revokes the account's refresh tokens. Before, a disabled or demoted user kept the roles
cached in their cookie until the session expired. Behaviour change: a role change signs that
user out of their existing sessions; a remembered sign-in re-authenticates with the new roles
(#581). - File storage: Behaviour change.
quota_bytesis now enforced for plain uploads as well as
resumable ones (stored bytes plus active reservations, serialised per tenant on Postgres); an
upload that would exceed it answers 413 and its object is removed. Previously only resumable
sessions were checked, and the setting was described as report-only (#586). - File storage:
DELETE /api/file_storage/files/{id}and the bulk delete now drop the stored
bytes only after the request transaction commits, so a failed commit no longer leaves a live
row with no bytes. A new hourlyfile_storage.reap_objectsbeat task (needs
background_tasks) retries objects of soft-deleted rows and removes objects left by uploads
whose row never committed, after twice the upload-session TTL (#582). - File storage: cancelling an upload still waiting for a free slot no longer uploads it later in
the background (#588). - Tenants: the organisation settings page and its menu item are registered only when the
settingsmodule is enabled; without it/tenants/settingsused to 500 with an
ImportError.file_storagenow orders afterbackground_taskswhen both are installed (#585). - Docker: Behaviour change. With
SM_USERS_BOOTSTRAP_PASSWORDunset, the entrypoint now
generates a random first-boot admin password and prints it (the compose file already said so),
instead of seeding the publicchangeme. An explicitchangemeis refused when
SM_ENVIRONMENTisproduction(the image default).docs/release.md's local rehearsal now
runs in a disposable worktree instead of ending withgit reset --hard HEAD~1(#587). - Retained logs: a NUL character no longer drops the whole log batch on Postgres.
- Users: after a self-service password change the same browser kept its page session but
every/api/users/me*call (including "Sign out everywhere") answered 401, because the
change deleted thesm_authaccess-token row that browser's cookie pointed at.
POST /api/users/me/passwordnow re-issues that cookie under the newsession_version
(keeping a remembered sign-in's window); other sessions and bearer tokens are still revoked.
The profile's password and sessions cards show the translated failure inline. - Users: the admin "Copy reset link" (and the bulk-invite fallback link) no longer points at
http://localhost:8000on installs that never setusers.base_url: an explicitly set
users.base_urlwins, thenHostSettings.public_base_url, then the origin the admin is
browsing. Emailed links are unchanged. - UX polish (#575):
/tenants/settingsshows translated labels instead of raw keys
(SettingDefinition.label_key/TenantSettingView.label_key, set bybranding; en + de);
signed-in error pages outside/admin(e.g. a 404 for an unknown URL) keep the app layout (sidebar, user menu); the tenant
invite form shows a translated inline error for an invalid email instead of the browser's
silent native validation.
Removed
- Committed QA scratch directories
qa-shots/,.qa/and.verify/(159 files); they are now gitignored (dataset/stays) (#511). - Unused
Comboboxshadcn component and the@base-ui/reactdependency (nothing imported it; Radix has no combobox, usecmdkCommand+Popover). Behaviour change for anyone importing@simple-module-py/ui/components/ui/combobox(#521). host/client_appno longer re-declares@simple-module-py/ui's runtime dependencies, nor@base-ui/react,@radix-ui/react-slot,autoprefixerorpostcss(nothing loaded them) (#521).
Added
- Typed API client (#504).
@simple-module-py/uishipscreateApi<paths>()(an
openapi-fetchclient with same-origin credentials,Accept: application/jsonand
the CSRF header),apiResult()/apiFetch()normalising every call to
{ ok, status, data, detail, fieldErrors, error }(network failure:status: 0),
anduseApiErrorToast(fallbackKey).smpy openapi gen-types [--module X]writes
each module'spages/api.generated.tsfrom its routes;make gen-api-types
regenerates them andmake ci-api-types-fresh(inmake lintand CI) fails on a
stale one.*.generated.tsis exempt from Biome and the 300-line cap. New modules
shippages/api.ts, the generated types and a Browse page that deletes through
the client.TranslationKeyis exported from@simple-module-py/i18n. csrfTokenInertia shared prop for signed-in sessions (#504), which the API client
sends asX-CSRF-Tokento the page's own origin. Anonymous pages never carry it.- Resource-scoped access (#526):
simple_module_core.resources(ResourcePolicywithfilter/can,ResourcePrincipal,ResourceType,ResourceTypeRegistry) and a newregister_resource_typeshook (read atapp.state.sm.resource_types);simple_module_db.resource_policy.OwnedResourcePolicy(owner OR public-for-view OR an injectedshare_exists, with a keyword-required bypass permission) so lists andpaginate()counts are filtered in SQL;simple_module_hosting.resource_deps.RequiresResource(policy, action, loader, permission=None)loads rows only through the policy: invisible → 404, visible but not allowed → 403, and admits API tokens only whenpermission=is set. Seedocs/framework/resource-access.md. - New optional
sharingmodule (simple_module_sharing, #526): tenant-scopedsharing_resource_sharetable (migrationf733c7938fd1, a new root and head labelledsharing),share_exists_for(...)forOwnedResourcePolicy, grant/revoke/list API under/api/sharing/shares(validates the resource type, the granter'sshareright on the row and the grantee via the user directory, which on a multi-tenant app must be a member of the active tenant: same error as an unknown id, and labels only for members; new core seamsimple_module_core.tenancy.tenant_member_ids/app.state.tenant_members, published bytenants; expiry checked in SQL; revoke hard-deletes) and an admin page at/admin/sharing/(en + de). Release note: register a PyPI pending publisher forsimple_module_sharingbefore the next release. - Server-rendered
<head>for Inertia pages (#535 step 1):inertia.render(..., head=Head(title, description, canonical, robots, og, twitter, json_ld))puts the tags in the first response so crawlers and link unfurlers see them without JS; they are also sent as the_headprop and re-emitted on client visits via Inertia'sserverHead(@simple-module-py/ui/lib/server-head). URL fields are http(s)-only, JSON-LD cannot close its<script>. Newpublic_base_urlhost setting (DB-backed, read per request, no restart) builds canonical /og:url; the requestHostis never used. Bothindex.htmltemplates updated. Body SSR remains open in #535. smpy docker add-workergenerates dedicated Python worker images with validated apt packages, optional extras, queue/module routing, and idempotent Compose includes (#540).- Statement DML is audited (#557):
simple_module_db.bulk_auditaudits ORM bulk
update()/delete()/insert()and Core DML onModel.__table__through
the engine's audit sink, onebulk_updated/bulk_deleted/
bulk_soft_deleted/bulk_restoredentry per row (ids prefetched with the statement'sWHERE
and tenant scope) up to 500 rows, else one"*"summary;bulk_insertedis one
summary per statement.execution_options(sm_audit=False)opts out;
record_bulk_audit(session, Model, action, ids, changes)recordstext()
writes; the newaudit_logsettingrefuse_unaudited_dml(default off) makes
the opt-out raiseUnauditedWriteError. No migration. - Legacy SPA mounts (#541):
ModuleBase.spa_mounts()returnsSpaMount(prefix, dist_dir, index="index.html", auth="required"|"public")entries served with anindex.htmldeep-link fallback (extension-less paths accepting HTML only; a missing asset stays a 404; hashed assetsimmutable; GET/HEAD only).auth="public"exempts GET/HEAD fromAuthMiddleware. A prefix overlapping a router, static mount or another SPA fails boot (SM032). The Inertia client leaves these prefixes to the browser:startSpaLinkInterception({ exclude })plus the framework shared propnativePrefixes, which also carries the new DB-backed host settingnative_link_prefixes(no migration). background_tasks: opt-in retained logs. Withlog_sink_enabled(default off;
log_sink_leveldefaultINFO,log_retention_daysdefault 7) web and worker
log lines are kept in the newbackground_tasks_log_recordtable, batched off
the logging path by the newsimple_module_hosting.log_sinks(LogSink
protocol,QueueSinkHandler: bounded queue that drops and counts instead of
blocking, secrets masked before queueing). The task detail page gains a Logs
tab,/admin/background-tasks/logssearches all retained lines, and
purge_old_executionsenforces retention. Migration1f5985daa10b(#537).background_tasks: tasks report progress withheartbeat(progress=(i, n), message=, phase=)
(buffered under the existing 30 s throttle,i == nwrites at once, never
audited), andGET /api/background_tasks/tasks/{celery_task_id}/statuslets the user who
enqueued a task read its status and progress withoutbackground_tasks.view
(markedAuthenticated(); tokens are refused)
(owner in the active tenant, orviewholders; anyone else gets 404; never
returns args, result or traceback).TaskExecutiongainsprogress_*and an
indexedowner_user_idstamped from the newsm_owner_idmessage header;
retries keep the original owner. The detail page shows a progress bar.
Migrationbb906e1a1089(first revision labelledbackground_tasks) (#536).- Distributed TTL leases in
simple_module_core.leases:lease(name, ttl)(an
owner-checked lock withtoken, a strictly increasingfence,refresh(),
release()andasync withwaiting) andsemaphore(name, slots, ttl)(at
most N holders across processes). Backed by Redis whenSM_REDIS_URLis set,
else the new framework tablesm_lease(migrationd263ea727663, a new root
labelledleases), else in-process;create_appand the Celery worker
bootstrap install it. An expired owner can neither refresh nor release (#539). - Optional Sentry error reporting:
pip install simple_module_hosting[sentry]and setSM_SENTRY_DSN(plusSM_SENTRY_ENVIRONMENT,SM_SENTRY_TRACES_SAMPLE_RATE). Web and Celery worker processes tag events with request id, tenant, user id, module and task, and scrub auth headers, cookies,token/codequery values and request bodies. Without a DSN the SDK is never imported (#538). - Sentry events no longer leak secrets the SDK's exact-name scrubber misses: auth-, session- and token-like headers (
X-Auth-Token,Proxy-Authorization,X-Forwarded-For), nested secrets inextra/contexts, tokens in log and HTTP breadcrumbs, credentials in exception messages, and Celery task args/kwargs are scrubbed for errors and transactions alike (#538). - In-process durable outbox delivery and lifespan-owned recovery sweeps, automatically enabled in eager mode (#565).
- Sentry scrubbing now covers every event field (tags,
server_name, top-level stacktraces), camelCase and short key names (basicAuth,pwd,otp), header/query lists of pairs, bytes, quoted and escaped-JSON values, wholeCookie:lines, passwords containing@, JWTs and bare long tokens; SQLdb.paramsare dropped. Behaviour change: a scrubber failure now drops the event instead of letting the SDK decide, and strings over 64 KiB are truncated (the old patterns were quadratic: a 20 KB string stalledbefore_sendfor over a minute) (#538). - Per-route rate limits (#529): modules declare
RateLimitRule(rate, key=...)in the new
register_rate_limits(registry)hook, and handlers use the
RateLimit("10/minute", key="user")dependency. Keys:user,ip,user_or_ip,
tenant; both apply to anonymous and signed-in requests and answer429with
Retry-After. forwarded_host_allowlisthost setting (#528): behind a trusted proxy (SM_TRUSTED_PROXY),
X-Forwarded-Hostreplaces the request host (andscope["server"]) when it matches an exact host or
*.example.comentry, sourl_forand subdomain tenancy see the public host. Empty (default) ignores
the header; tenant subdomains need*.<subdomain_base>in the list.- Configurable CORS (#527).
cors_allow_origins(pluscors_allow_credentials,
cors_allow_methods,cors_allow_headers,cors_expose_headers,cors_max_age;
restart-required) registers a policy on/api/; modules add policies with the new
register_cors_policieshook. The dispatcher runs beforeBodyLimit, so preflights
never reach auth and a 413 keeps its CORS headers.file_storageserves public
files cross-origin (Range-capable) unless the newpublic_files_corssetting is off. - PostgreSQL pool, connect, statement and idle-in-transaction timeout settings for web and worker engines, with worker pool and statement overrides (#531).
- The OpenAPI export declares
bearerAuthandsessionCookiesecurity schemes and a
securityentry per operation ([]for public routes), so generated clients know where
credentials go (#534). NewAuthenticated()marker dependency
(simple_module_hosting.token_guard) for routes any signed-in user may call, and diagnostic
SM031(WARNING, dev boot) for/apiroutes with no gate and no public match. Marked
Authenticated():GET/POST /api/tenants/,POST /api/tenants/{tenant_id}/switch,
DELETE /api/tenants/current/membership,POST /api/tenants/invitations/accept,
GET/PATCH /api/users/me,POST /api/users/me/password,
POST /api/users/me/sessions/revoke-all. Tokens are still refused on these. - Release process (#510):
release.ymlruns the whole PR suite (pr.ymlvia
workflow_call) against the dispatched commit andbuildneeds it, so nothing
is built or published from a red commit. The duplicated bump + lockfile steps
are one composite action (.github/actions/bump-version).finalizecuts a
dated CHANGELOG section withscripts/cut_changelog.py(renames[Unreleased],
inserts a fresh one, updates compare links) and the GitHub release notes come
from that section.docs/release.mddocuments the package-version vs
FRAMEWORK_API_VERSIONsplit; a test pins that the module template's
requires_frameworkcontainsFRAMEWORK_API_VERSION. simple_module_core.deprecation(deprecated,deprecated_module,deprecate)
registers every shim with aremove_inrelease;host/tests/test_deprecation_expiry.py
fails once the framework version reaches it (#515,docs/framework/deprecations.md).- German catalogues for all framework and module screens, available by adding
detoi18n_supported_locales,
with enforced key and placeholder parity in CI (#492). make ci-doc-paths(scripts/check_doc_paths.py, part ofmake lintand the
python-lintjob) fails when a backticked repo-rooted path indocs/,
CLAUDE.md,README.mdorskills/**/SKILL.mddoes not exist; tutorial
examples go inscripts/doc_path_allowlist.txt(#519).- New
docs-buildPR job (make ci-docs-build) builds the VitePress site, which
docs/superpowers/anddocs/plans/are excluded from (#519). AuditLink.for_model(Model, url_template=..., ...)fillsentity_type(class
name) andtable_namefrom the model; every module'sregister_audit_links
now uses it.simple_module_db.independent_session(app)is an async context
manager for out-of-request sessions that commits on success and rolls back on
error (permissionsstartup replay andapi_tokenslast-used write use it)
(#523).- Framework pagination helper (#513):
simple_module_db.PageParams,Pageand
async paginate(session, stmt, params)(clamps to the last page, never a
negative offset, counts through soft-delete/tenant filters),clamp_page/
total_pages;simple_module_hosting.paginationadds the strict
page_params(n)and clampinglenient_page_params(n)dependencies; and a
PaginationBarcomponent in@simple-module-py/ui(ui.pagination.*keys). - New
mailmodule (simple_module_mail) owns outgoing mail: the SMTP/console
transport, its settings (/admin/settings/, packagemail) and the on-demand
mail.mailerhealth check behind "Test mailer connection". Modules send through
simple_module_core.mail(current_mail_sender()/resolve_mail_sender(db)),
which falls back to logging when no transport is installed (#496). - Tenant invitations are now emailed (#496). With
background_tasksinstalled
the email goes through the durable outbox (tenants.send_invitation_email,
retried); otherwise it is sent after the request commits. A link is only
emailed whenpublic_base_urlis set. The invite API response gains
emailed, and the invite form says whether the link was emailed. captured_mailpytest fixture insimple_module_testrecords sent mail.- User directory protocol (#501):
simple_module_core.user_directory
(UserDirectory,DirectoryUser,DirectoryStats,NullUserDirectory,
get_user_directory/set_user_directory).usersinstalls
UsersDirectory,keycloakinstallsKeycloakDirectory(users who have
signed in; roles unknown).audit_log,dashboard,file_storage,
api_tokensandpermissionsread users through it instead of importing
users.models, so actor names and search, dashboard tiles and uploader
names now work under Keycloak instead of querying the emptyusers_user
table.get_manyreturns an entry for every caller spelling of a known id
(parse_user_idsmaps a uuid to all of them). See
docs/framework/user-directory.md. - Activity feed protocol (#501):
simple_module_core.activity_feed.
audit_loginstalls it; the users edit page's Recent activity card reads
it, which removes theusers↔audit_logimport cycle.
Changed
- Behaviour change: the
simple_module_testplugin no longer refuses-n
on Postgres: each pytest-xdist worker creates and uses its own database
(<db>_gw0,<db>_gw1, ...) and pointsSM_TEST_DATABASE_URLat it, so the
role needsCREATEDB.make test-py-pgnow runs with
-n $(PYTEST_WORKERS) --dist loadgrouplikemake test-py
(PYTEST_WORKERS=0keeps the old serial run on the named database). simple_module_teston Postgres builds the schema once per process and, before
each later test, deletes every row and restarts the sequences instead of
dropping and re-creatingpublic(~0.05 s instead of ~0.25 s per test). A
directreset_schema()call or any change to the catalog (a test's own
tables, extensions, constraints) falls back to the full rebuild.create_appno longer resolves every route's dependencies at build time
(~0.5 s for the full module set, ~70% of a test's setup): module routers now
get their prefix frominclude_router(prefix=...), which skips FastAPI's
resolving empty-path check (effective paths and the OpenAPI document are
unchanged), and the route prefix guard and the SPA overlap check read paths
from FastAPI's include contexts without resolving routes. Outside
environment="testing"the lifespan still resolves all routes at startup,
so the first requests after boot stay warm. The SQLite and Postgres suites
run ~45% faster.- tenants and api_tokens pages call their APIs through the typed client and take
typed component props (#504). Behaviour change: the api_tokens pages no longer
receive the unusedapi_urlprop. - users, background_tasks, branding, settings, file_storage and the first-run setup
wizard call their APIs through the typed client (#504), completing the migration.
Behaviour change: failed requests from these pages now show translated error
toasts (the server'sdetailor a localized fallback) instead of raw or generic
English text.make lint/ CI reject rawfetch(in shipped.ts/.tsx
(ci-check-no-raw-fetch,scripts/check_no_raw_fetch.py) unless marked
// raw-fetch-ok: <reason>. - file_storage's public file routes (
/api/file-storage/public/{file_id}and
.../{filename}) now have distinct OpenAPI operation ids (file_storage_public_file,
file_storage_public_file_named); the cosmeticfilenameis a path segment on the
named route only. Paths and responses are unchanged. - Typed page props (#505):
@simple-module-py/uiregistersSharedProps(now includingi18n) with
Inertia, sousePage().props.authis typed, and exportsusePageProps<T>()for components and
layouts. Pages take typed component arguments. Theas unknown ascasts in module pages,packages/ui
andhost/client_appare gone, andmake lint/ CI reject new ones (ci-check-no-double-cast)
unless marked// cast-ok: <reason>.docs/frontend/shared-props.mdand the
simple-module-inertia-pagesskill no longer teach the cast. - Behaviour change: bulk and Core DML writes on audited models now produce
audit entries (actionsbulk_*), in requests and Celery workers. Statements
that set only excluded columns (the outbox,TaskExecution.heartbeat_at) are
still not recorded. Opt a statement out with
execution_options(sm_audit=False)(#557). - Behaviour change:
simple_module_dbno longer emits theentity.created/entity.updated/
entity.soft_deletedlog lines for models flagged__audit_exclude__ = True(AuditEntry,
OutboxMessage,RetainedLog), so retained logs cannot re-log their own writes (#537). - Behaviour change:
rate_limit_authenticated(off by default) now keeps one budget per
signed-in user instead of one per client IP, so users behind one NAT no longer share it (#529). scripts/bump_version.pyalso bumps everymodules/*/package.json; all
package.jsonversions are now in lockstep with the Python packages (they were
0.1.0, keycloak 0.0.0) (#510).usersandkeycloakregister their anonymous login routes with
register_public_routesinstead ofAuthProvider.get_public_paths(), so the
maintenance gate andAuthMiddlewareread one mechanism (#515).simple_module_hosting.settings.Settingsis documented as the combined host
settings type, not a "back-compat shim" (#515).host/client_appnow depends on@simple-module-py/ui;tailwindcssfloor raised to^4.3.3to match the installed version (#521).@simple-module-py/uinow declaresreact-hook-form, whichcomponents/ui/form.tsximports (it was only reachable through the host app) (#521).- Docs: stale example paths in
README.md,CLAUDE.md, the testing overview and the
quickstart corrected; the historicaldocs/perfnote is marked as exempt (#519). - Bound FastAPI to
>=0.140,<0.157in framework and module dependencies; a
contract test guards the route guard's private FastAPI/Starlette APIs (#516). - Declare and document the optional
file_storage[tasks]extra for the lockstep
background-tasks package; documentDurableTransportas the durable-event
extension point and verify uploads without background tasks (#502). - Users, file_storage, background_tasks (executions and outbox), audit_log,
settings and tenants lists now share the pagination helpers instead of
per-module copies (#513). Browser view routes (background_tasks
including the outbox page,file_storage,tenantsadmin,usersadmin) clamp
?page=0/negative/garbage to page 1 rather than reject; every JSON list API
(background_tasksexecutions and outbox,audit_log,file_storage,
usersadmin) answers 422 forpageoutside 1..1,000,000 andper_page
outside 1..200, where some used to acceptpage=0or an unboundedper_page
or forward a huge page to SQL as an out-of-range offset. The outbox API now
reports the clamped page when asked past the end. The audit log, executions
and outbox tables usePaginationBar; their privateshowing/previous/next
locale keys are removed in favour ofui.pagination.*. - One module scaffolder (#509).
make new-modulenow calls the CLI's
create_module(framework_repo=Trueoverlay) instead of ~980 lines of
f-string templates inscripts/_templates_*.py, which are deleted, so
smpy create-module/smpy newandmake new-moduleshare one set of
templates/module/files. Both now emitconstants.py,models.py,
contracts/,service.py,deps.py, permission-guarded REST + Inertia
endpoints, Browse/Create/Edit pages, a menu item withlabel_key,
register_permissions,locale_dirsandlocales/en.json(the CLI sample
pageIndex.tsxis replaced byBrowse.tsx).make new-modulealso adds
the module to thepublish-pypimatrix and runsnpm installand
make gen-i18n; a fresh module passesmake lint. The scaffold tests live in
framework/cli/tests/only. audit_loganddashboardno longer depend onUsers(metadepends_on
and package dependency);background_tasksdrops its unusedUsers
dependency (#501).api_tokensmovesUserstooptional_depends_onand
its package depends onfastapi-users-db-sqlalchemyinstead of
simple_module_users, so a Keycloak install keeps service keys (personal
tokens are refused there).permissions(role pages) still depends on
Users.- Behaviour change (#501): dashboard user figures the auth provider cannot
give arenullin/api/dashboard/statsand their tile is hidden (with no
provider, all three; under Keycloak,users_created_this_month). Personal
API tokens are refused for an owner whose roles the directory cannot read
(Keycloak), as before. audit_log.resolve:resolve_actors,actor_filterandactor_linktake
theUserDirectory;USER_ENTITY_TYPEis gone (use the directory's
entity_type).audit_log.export.stream_csvtakes the directory too.
PermissionServiceandApiTokenServiceaccept a directory (defaults to
the null directory). With no directory, the audit Actor search matches the
term as an exact actor id (#501).file_storage.resolve.resolve_uploader_labelsis now core's
resolve_user_labels(directory, db, ids)(the directory is the first
argument), andusers.audit.resolve_user_labelsdelegates to it (#501).smpymodule catalog:background_tasksrequiressettingsinstead of
usersanddashboardrequires nothing; a test pins that every entry's
requirescovers its module'sdepends_on(#501).- Behaviour change (#501): outside development, a module whose
depends_onnames a module that is not loaded now refuses the boot with one
MissingDependencyError(anInvalidModuleError) listing every
(module, dependency)pair, instead of a warning and a module running
against tables nobody owns. Development still logs it;make doctor
reports it as the newSM030error. SelectingkeycloakdropsUsers, so
a Keycloak install must leave outpermissions(SM_MODULES_ENABLED). The outbox worker still sorts leniently. - New
ModuleMeta.optional_depends_on: modules to sort after when loaded,
never required (#501).users,tenants,file_storage,
background_tasks,site_lock,api_tokensandkeycloakmove their
ordering-onlySettingsentry there, so the boot order is unchanged and
thesettingsplugin can still be disabled (#500). - The module-settings mechanism moved from the
settingsplugin into the
framework (#500):register_module_settings/ModuleSettingsRegistryto
simple_module_core.module_settings, hydration (hydrate_settings,
build_settings,OverrideSource, field helpers) to
simple_module_core.settings_hydrate, secret-field masking to
simple_module_core.secret_fields, and theSettingmodel, its constants,
the read-only override queries andhydrate_settings_syncto
simple_module_db.stored_settings. The table (settings_setting) is
unchanged; no migration. The hosting layer imports these statically instead
of reaching the plugin throughimportlibstrings, and builds its pre-app
and secret-key SQL from the shared column constants. - Behaviour change (#500): registration and boot-time hydration no longer
need thesettingsplugin. The registry is created onapp.stateon first
use,hostsettings are always registered, and the lifespan hydrates from the
table directly, so module and host settings still come from the DB with the
plugin disabled (it keeps the admin UI, hot reload and the key/value API).
The pre-app host read and the generatedSM_SECRET_KEYare likewise stored
and read without it; outside development an unreachable database now refuses
the boot even when the plugin is not installed. - Out-of-tree hosts without the
settingsplugin (#500): the framework now
always contributessettings_settingto the migration metadata, so the next
alembic revision --autogeneratewill emit acreate_tablefor it. Keep that
operation (the framework reads the table at boot). make new-module/smpy create-modulescaffolds subclassDbBackedSettings
and register throughregister_module_settingsinstead of assigning
app.state.<pkg>directly (#500).- SM009 now detects dynamic framework imports of plugin packages, including importlib aliases and fixed f-string package prefixes (#500).
- CI and local lint now share one definition (#508).
make ci-js-typecheck
depends ongen-pages, somake lintworks on a fresh clone. New targets
ci-check-metadata(now also runscheck_readmes.pyin CI),ci-i18n-fresh
(inmake lint),ci-css-buildandci-doctor;pr.ymlcalls them. CI gains
adoctorjob (make doctoragainst a freshly migrated SQLite DB) and a
separatepostgres-tests.ymlworkflow (make test-py-pg; nightly, manual and
path-filtered PRs, not part ofpr-checks). - Biome runs with
--error-on-warnings; the seven unusedtype Reactimports
and the useless fragment inErrorPage.test.tsxare removed (#508). uv.lockis now committed (the ignore rule is gone), keys the CI caches, and
CI installs withuv sync --locked(UV_SYNC_FLAGS);make installis
unchanged (#508). The release workflow runsuv lockafter
the version bump so the lock follows the lockstep versions.- Behaviour change: the
usersmailer settings (mailer,smtp_*) moved to
themailmodule asbackendandsmtp_*; a data migration (d7b3a9e5c1f2)
moves stored values (#496).simple_module_usersnow depends onsimple_module_mail, and
theusers.mailerhealth check is nowmail.mailer. Theusers.Mailer
protocol is unchanged. tynow checksinvalid-argument-typeandinvalid-assignmentin shipped
code (tests stay exempt), and the ~150 mypy-style# type: ignore[...]
comments, which suppressed nothing under ty, are gone (#507). Remaining
third-party false positives carry# ty: ignore[<rule>].
settings.registration.register_module_settingsis generic over the settings
class, soservices_factoryreceives the concrete type.- Behaviour change (#507): a tenant resolver that returns a
UUID(bare,
in a pair or inTenantResolution) now binds its string form; any other
non-strtenant id or source raisesTypeErrorinstead of flowing into
tenant filters. - Behaviour change (#507):
UsersSettings.cookie_samesiteonly accepts
lax,strictornone(case-insensitive). Any other stored value is
dropped with a WARNING and the defaultlaxapplies.
The settings editor treats aLiteral[...]of strings as a string select
(it was filed underjsonwith a free-text box). get_file_storage_serviceraises a clearRuntimeErrorwhen the storage
backend has not been initialised, instead of building a service around
None(#507).RequiresPermissionraisessimple_module_core.permission_denied.PermissionDenied
(anHTTPExceptioncarrying.permission); the 403 handler reads the
attribute instead of parsing the message (#518).PERMISSION_DENIED_PREFIX
stays importable.- Settings cross-field validators (background_tasks, file_storage, site_lock,
users, keycloak) name their related fields, so lenient hydration drops only
the stored culprit instead of resetting the whole module (#558). - The tenancy scope walk is about 2× cheaper for statements that pass through
unchanged (get 26→15 µs, pagination count 83→44 µs) (#559). - Behaviour change (#494): a pre-app settings read that fails (connection,
query, import error) is no longer treated as a fresh install. Outside
development and testing the host and the Celery worker refuse to boot with
HostSettingsUnavailableErrorinstead of building with defaults (which
silently disabledmulti_tenant). Only a missing settings table falls back
quietly. Likewise the secret key: an ephemeral key is used only before the
settings table exists; otherwise boot fails withSecretKeyUnavailableError,
and a blank stored key is replaced atomically. - Behaviour change (#495): the session cookie is
Secureoutside
development and testing. NewHostSettings.session_cookie_secure
(SM_SESSION_COOKIE_SECURE); setSM_TRUSTED_PROXYbehind a TLS terminator,
andfalseonly for installs that serve browsers plain HTTP on purpose. - Behaviour change (#498): with
audit_loginstalled, writes made in Celery
tasks and durable outbox handlers are audited (includingTaskExecution
creation and status changes; its heartbeat, timestamps and retry counter are
excluded, so a heartbeat-only update writes no audit row, and the
OutboxMessagedelivery bookkeeping is not audited at all). The audit callback is bound per engine instead of a
process-global, through the newsimple_module.audit_sinksentry-point
group. Worker tests that build a database without the audit table need the
sink off (seebackground_tasks/tests/conftest.py). - Behaviour change (#530): password-login lockout now holds across workers,
source IPs and restarts.users.login_lockout_account_failures(new, default
10) failures for one account withinlogin_rate_limit_window_seconds, from
any network, lock it forlogin_rate_limit_cooldown_seconds: every password
login for it answers 429 withRetry-After, the correct password included;
the failure that trips the lock is itself answered 429. Attempts are counted
before the password is checked (a correct password gives the slot back), so
a parallel burst gets no more tries than the threshold.
login_rate_limit_failures(5) attempts from one IP for one username per
window are admitted. Unknown usernames go through the same rules on the rate
limit store, so a 429 does not reveal whether an account exists.
Counters live onusers_user(a migration addsfailed_login_count,
last_failed_login_atandlocked_until) and on the host rate-limit store
(Redis whenSM_REDIS_URLis set). The per-workerLoginRateLimiteris gone.
A successful password reset lifts the lock and clears the failure count
(audited, like an admin unlock). - Behaviour change (#530):
POST /api/users/auth/tokennow has the same
lockout and throttle: 429 while locked; bad credentials are still 401. Its
email lookup is now case-insensitive, matching the session login. - Behaviour change (#530): the stock fastapi-users auth router is no longer
mounted, so/api/users/auth-inner/loginand/api/users/auth-inner/logout
are gone. Use/api/users/auth/loginand/api/users/auth/logout. - Behaviour change (#543): with
allow_signupoff, an OAuth sign-in no
longer creates an account. It answers 403OAUTH_SIGNUP_DISABLEDunless the
identity is already linked or its email matches an existing account, which
still link and sign in. - Behaviour change (#547):
users.require_verificationis honoured. With
it off, no verification email is sent, the register page says so, and
password login no longer requires a verified email — including for accounts
registered while it was on. Such accounts stay unverified, and an OAuth
sign-in whose email matches an unverified password account is refused with
403OAUTH_ACCOUNT_NOT_VERIFIEDinstead of linking to it (an account already
linked to that OAuth identity still signs in). With it on,
POST /api/users/auth/tokennow refuses an unverified account with 400
LOGIN_USER_NOT_VERIFIED, as the session login already did; it used to mint
bearer tokens for one. - Behaviour change (#544):
file_storagecallers without
file_storage.manage(granted to admins and totenant:admin/tenant:owner)
see, download, change and delete only files they uploaded; anyone else's
file is a 404. Files with no recorded uploader are visible to managers only. - Behaviour change (#545):
file_storageserves a stored type inline only
from an allow-list (raster images, PDF, JSON/GeoJSON, plain text, CSV, opaque
binaries, audio/video/font). Everything else, any*+xmland any malformed
value included, is a sandboxed attachment. POST /api/users/auth/tokenreturnstoken_type="Bearer", and every bearer
resolver (users, keycloak, api_tokens) accepts the scheme in any case (#533).- Behaviour change (#486): for settings classes that read the environment
(todaybackground_tasks,SM_BG_TASKS_*), an env var now beats a value
stored from the admin UI, andSM_REDIS_URLbeats a stored broker/result
backend. Saving a field the environment sets returns a 422 on that field. - Behaviour change (#488): self-registered users now get the standard
userrole; OAuth-provisioned users get it only whileallow_signupis on.
A new data migration (b5018b617142) seeds the role on fresh installs
only (no rows inusers_user), so a role an admin deleted is never brought
back. On an existing install without auserrole, signups still get no
role and a warning is logged — create the role in Admin → Roles to opt in. - Contract change (#489):
StorageBackend.presigned_get_urltakes
keyword-onlycontent_disposition/content_type. - Behaviour change (#456): the OpenAPI schema moved from
/openapi.jsonto
/api/openapi.json, and is no longer served outside development unless
openapi_public(SM_OPENAPI_PUBLIC) is on. The bare/openapi.jsonwas
previously served, and anonymous, in production.
Fixed
- Background task stuck sweeps and retention purges now use DB-backed settings so admin edits apply instead of reading only environment variables (#565).
- User directory follow-ups (#501):
audit_loginstalls its activity feed in
on_startup(cleared on shutdown) instead ofregister_settings, which
trippedSM012;resolve_user_labelsomits users whose label is empty so
callers show the stored id; theusersandkeycloakdirectories compute
their stats in one query instead of two or three.
Deprecated
AuthProvider.get_public_paths()is no longer part of theAuthProvider
protocol; third-party providers that still define it keep working with a
DeprecationWarning. Removed in 0.1.0 (#515).- Every existing shim now has an enforced removal release of 0.1.0 (#515):
host/routes_legacy.py,settings.registration/settings.module_registry/
settings.hydrate,props.lazy()and the one-argumentregister_event_handlers
form (which now warns). settings.registration,settings.module_registryandsettings.hydrate
re-export the framework objects above and emit aDeprecationWarning;
settings.models.Setting,settings._secretsand the moved names in
settings.store/settings.constantsre-export them silently (the same
objects, never a secondSettingmapping). Removal no earlier than 0.1.0
(#500).
Added
-
TenantDeletedevent andTenantService.delete_tenant(#491). No endpoint
yet: what deleting a tenant means for other modules' rows is still open. -
simple_module_test.wait_for/wait_for_sync;make ci-check-test-sleeps
flags unmarked fixed sleeps in tests (#512). -
scripts/check_metadata.pyasserts the tyextra-pathsand pytest
testpathslists match the packages on disk, inmake lintand CI (#520). -
Tests for the api_tokens, feature_flags and background_tasks pages,
tenants/shared_props.pyandfile_storage/upload_guards.py(#514). -
PATCH /api/users/admin/{id}/unlocklifts a login lockout early (audited).
UserListItemcarrieslocked_until; the users list shows a "locked" badge
and the edit page a "Locked until" row with an Unlock action (#530). -
app.state.sm.rate_limit_storeexposes the host's rate-limit store to
modules.WindowStoregainspeek()(read without counting),reset()and
hit(..., sliding=True)(#530). -
Durable event delivery (#459).
bus.subscribe_durable(EventType, handler, name=...)andawait bus.publish_durable(event, db)in
simple_module_core.events; handlers are called as
await handler(event, ctx: DurableContext).background_tasksinstalls the
transport: an outbox table (background_tasks_outbox, migration
e878882924e7) written in the publisher's transaction, a post-commit
background_tasks.dispatch_outboxtask, and a 30 s beat sweep
(background_tasks.sweep_outbox) for crashes between commit and publish.
At-least-once delivery with exponential backoff (10 s doubling, 1 h cap);
writes throughctx.dbcommit with the delivery mark and land exactly once
(a handler committingctx.dbitself is refused withHandlerCommitError);
other side effects are at-least-once. Handlers run in the scope recorded at
publish (scope: the publisher's tenant;all_tenants()for a publish made
insideall_tenants(); no tenant — strict — for one made with none bound),
with a timeout inside the lease, and
may publish durably themselves, in a worker too. Timestamps come from the
database clock. Rows godeadafteroutbox_max_attempts(8); an unknown
handler (rolling deploy) is retried like a failure. New admin page
/admin/background-tasks/outbox(retry dead, discard) and API under
/api/background_tasks/admin/outbox. New settingsoutbox_max_attempts,
outbox_sweep_interval_seconds,outbox_lease_seconds,
outbox_handler_timeout_seconds. Delivered rows are
purged afterretention_days. Without background_tasks,publish_durable
raisesDurableDeliveryUnavailable;publish/publish_nowaitare unchanged.
Newsimple_module_db.no_tenant()context: no tenant bound and outside any
all_tenants(). -
New
api_tokensmodule (#455): personal access tokens (smpat_…) and
service API keys (smsk_…). Onlysha256(token)is stored and the secret
is shown once; a token is resolved by one indexed lookup per request (no
cache), so a revoke applies on the next request. Every token carries scopes
that cap the principal's permissions on top of the owner's live grants (an
admin's token is worth its scopes only). Service keys get roles chosen at
creation (neveradmin); every token is pinned to the tenant it was created
in (a PAT only while its owner is still a member). Self-service page at
/api-tokens/(user menu), admin screen at/admin/api-tokens/; tokens can
only be managed from a cookie session, withRequiresCsrf. New PyPI package
simple_module_api_tokens; not added to thesmpy newscaffold. Needs a
migration (branch_labels = ("api_tokens",)). -
UserContextgainsauth_kind("session"by default;"pat"/
"service"for tokens) andscopes(None= uncapped), carried through
from_user,to_session_dictandfrom_session_dict.auth.contracts.tokens
holds the kinds and thesmpat_/smsk_prefixes. -
simple_module_hosting.permissions.apply_scope_capand
simple_module_core.permissions.is_admin_principal. A role-only admin gate
should useis_admin_principal(user), which isFalsefor any token. -
simple_module_hosting.token_guard(#455): an app-wide dependency that
refuses an API token (403) on every route without a permission gate
(RequiresPermission/require_permission) and on every page; a public
route serves it as anonymous.TokenAllowed()opts a route in explicitly.
Sessions and provider bearer tokens are unaffected. Behaviour change for
any module that relied on a token reaching an ungated route: gate it with a
permission, or mark itTokenAllowed. -
auth.contracts.tokens.AUTH_KIND_BEARER: ausersaccess token or Keycloak
JWT now reportsauth_kind == "bearer"(was"session"). Same rights; it
only stops such credentials from managing API tokens. -
Resumable multipart uploads for
file_storage(#454):POST /api/file-storage/uploads
sessions with presigned S3 part URLs (or relayed parts on the filesystem backend),
4-way parallel parts with retry andlocalStorageresume in the Files page,
s3_public_endpoint_url, CSPconnect-srcfor the S3 origin, and an hourly
file_storage.reap_stale_uploadsjanitor (withbackground_tasks). Sessions count
against the quota and the assembled size is re-verified at completion. Migration
a659221c464baddsfile_storage_upload_session. -
background_tasks Celery config (#447, #448, #450): a module can ship a light
task_config.py(TASK_ROUTES,QUEUES,BEAT_SCHEDULE) read without
importingtasks.py;task_routes_overridere-routes tasks at runtime and the
Workers page lists the effective routes. New restart-required settings:
visibility_timeout_seconds,worker_max_tasks_per_child,
worker_max_memory_per_child_kb,task_reject_on_worker_lost,
task_soft_time_limit,task_time_limit,worker_concurrency; raw Celery conf goes in the environment-only
SM_BG_TASKS_CELERY_OVERRIDES(serializer/import/code-loading keys refused).SM_BG_TASKS_WORKER_QUEUES/_INCLUDE_MODULES/_EXCLUDE_MODULES
(comma-separated, environment only, never DB-backed;SM_BG_TASKS_WORKER_QUEUES=a,b) limit which modules'tasks.pya process
imports (autodiscovery and the eager beat import);make workerpasses-Q.
make doctorwarns (SM028) on a top-level heavy import intasks.py. -
OpenAPI export (#456):
smpy openapi export [--out] [--module]/make openapi
write the/api/**schema without starting a server; operationIds are
<module>_<function>; view routes are excluded; new host settings
api_title,api_version,api_description,openapi_public. See
docs/guide/openapi-clients.mdfor hey-api and Dart CI recipes. -
Postgres extensions and column types (#458):
ModuleBase.db_extensions()
declares the extensions a module needs (["ltree"]);ensure_db_extensions
runs fromenv.py(host and scaffold) before the revisions, emitting
CREATE EXTENSION IF NOT EXISTS(also in offline SQL) and raising
UnsupportedDatabaseErroron SQLite.simple_module_db.types.LTreeand
TSVectorare native on Postgres andTEXTon SQLite; autogenerate renders
them with the right import. New doctor codeSM029, and apostgrespytest
marker (skipped unlessSM_TEST_DATABASE_URLis Postgres). The Postgres test
fixtures re-create declared extensions after each schema reset. No migration. -
from background_tasks import heartbeat(#451): a long-running task calls
heartbeat()to refreshheartbeat_at(throttled to one write per 30 s; a
no-op outside a task).@shared_task(..., stuck_after=<seconds>)sets a
per-task stuck threshold that the sweep honours ahead of
SM_BG_TASKS_STUCK_AFTER_SECONDS. No migration. -
from background_tasks import enqueue_after_commit(#452): publishes a task
only once the caller's transaction commits (nothing on rollback), and
returns the reserved task id so a row can name it up front. -
HTTP
Rangesupport on file downloads (#453): single ranges answer206
(416when unsatisfiable, multi-range falls back to200), honouring
If-Range. Storage backends gainget_range(key, start, end)(inclusive
end); a third-party backend without it keeps serving full200s. -
smpy host gen-i18nand a scaffoldmake gen-i18ntarget (#449). -
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.tenancyexposes
tenancy_mode(app),single_tenant_id(app),require_tenant(...)(binds
the single-tenant id, or the request's tenant elseon_missing) and
tenant_vary(request);TenancyModelives insimple_module_core.tenancy.
Modules no longer need to read the middleware stack. -
NativeSelecttakes awrapperClassNamefor sizing the control (#421).
classNamestill targets the<select>. -
The
/setupwizard ships with the framework (#351). Since 0.0.33
SetupMiddlewareredirected a fresh install to/setup, but the route and
page lived only in this repository's unpublished host, so any other host got
/→/setup→ 404.create_appnow mounts the wizard
(simple_module_hosting.setup_wizard) andgen-pagesregisters its page
(Setup/Wizard) from the wheel; a host needs no setup code and should delete
any copy it carries. Steps complete from the browser through a new
SetupStep.action(SetupAction/SetupFieldinsimple_module_core),
POSTed to/setup/steps/<id>with the session CSRF token. An action runs only
while its own step is pending (409 otherwise);usersships the
first-administrator action, which re-checks under a database lock inside the
inserting transaction so concurrent requests create one superuser. Required
steps without an action are logged at boot. The old host-only
/setup/administrator,/setup/migrationsand the UI-less
/setup/site-basicsendpoints are gone. -
Postgres test runs (#343) —
SM_TEST_DATABASE_URLpoints the
simple_module_testfixtures at Postgres, andmake test-py-pgruns the
whole Python suite there. The schema is reset once per test, soappand
db_sessionsee each other's rows as they would in production. -
tenantsmodule — SaaS organisations: tenants, many-to-many memberships
with per-tenant roles (owner/admin/member, surfaced astenant:<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: anEntitlementProvideron
app.state.tenants.entitlements(seat limits enforced, HTTP 402), lifecycle
viaTenantService.set_status, and after-commit domain events. See
docs/framework/multi-tenancy.md. -
simple_module_db.tenant_context()/all_tenants()and the
all_tenants=Trueexecution option, for acting as one tenant — or
deliberately across tenants — outside a request. -
TenantMiddlewareconsultsapp.state.tenant_resolverwhen a module
registers one. -
background_taskscarries the enqueuing request's tenant into the Celery
task and restores it around the task body. -
Doctor check
SM024: a unique key on aMultiTenantMixintable 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_tasksinstalls a Redis pub/sub transport on
the connection it already configures, so a write drops the matching entry in
every worker instead of only the one that performed it.
Turn it off withSM_BG_TASKS_BROADCAST_INVALIDATIONS=false; rename the channel
withSM_BG_TASKS_INVALIDATION_CHANNELfor every app on a shared Redis server
(pub/sub ignores the database index, so DB 4 vs DB 5 does not isolate it).
See docs/framework/invalidation.md (GH #318). -
userspublishes itssession_versionbump on that bus, so "sign out
everywhere" and a password change stop being honoured across every worker at
once rather than after each worker'susers.session_version_cache_ttl_seconds
window. The TTL now bounds a dropped message rather than every cross-worker
revocation; installs without a reachable Redis keep the previous behaviour. -
Request-scoped database sessions now expose
session.on_commit(callback)for
synchronous or asynchronous cache refreshes and other derived state. The
framework invokes callbacks only after a successful commit and discards them
on rollback or commit failure. -
Every
smpy newscaffold now ships Docker assets by default: a multi-stage
docker/host.Dockerfile(uv + Node builder that runsgen-pagesbefore the
Vite build, slim non-root runtime that applies migrations on start), a
docker-compose.ymlmatched to the--dbchoice (appon a SQLite named
volume, orpostgres+app— migration histories are dialect-frozen at
autogenerate time, so containers run the same DB the migrations were
generated against), plusredis/worker/beatreusing the app image when
background_tasksis selected, a.dockerignore, andmake docker-up/
docker-build/docker-downtargets. Previously Docker files only
appeared withbackground_tasks, and their frontend stage couldn't build
real apps (nogen-pagesstep). The separateworker.Dockerfileis gone;
worker/beat run the same image with a celery command.smpy newalso
generates realSM_USERS_*_TOKEN_SECRETvalues into.env.exampleso the
production-mode containers passUsersSettingsboot validation.
Changed
- The
usersandkeycloakauth providers returnNoneat once for a bearer
value with anapi_tokensprefix instead of looking it up (#455), leaving it
to the principal resolver chain. require_permission(auth) skips its admin-role shortcut, and
resolve_principal_permissions/resolved_permissions_forcap the set,
when the principal carriesscopes. The/adminindex, the Doctor page and
the maintenance bypass no longer admit an admin's token on the role alone.- The
tenantsresolver binds a service principal to its pinned tenant and
ignores the tenant header and subdomain for it. make new-module: the scaffoldedtsconfig.jsonno longer setsbaseUrl
(removed in TypeScript 6),views.pyimportsInertiaResponsefrom
simple_module_inertia, andpackage.jsonpeers@inertiajs/react^3.7.0.StoredFile.checksum_sha256/StoredFileOut.checksum_sha256are now nullable (#454):
files uploaded through the multipart flow have no checksum until
file_storage.compute_checksumruns, andETags fall back to id + size.- Dashboard stats are admin-only on single-tenant installs too (#464).
dashboard.viewwas mapped to the ordinaryuserrole when
multi_tenantwas off, so any self-registered account could read user
counts and system info. Onlyadminholds it now; grant it to another role
in the role editor to restore the old behaviour./dashboard/still renders
for everyone, without the stats. - Error-page copy moved from
host.error.*toui.error_page.*(#475),
shipped withsimple_module_hosting'suicatalog so scaffolded apps get
it. A host that overrideshost.error.*in
host/locales/overrides/<lang>.jsonmust rename those keys. - An anonymous request to a path that matches no route now gets the 404 page
instead of a redirect to sign-in (#473). Every path that matches a route is
gated exactly as before. TenantMiddlewarenow varies onCookiewhen the tenant came from the
session, and onCookie, Authorizationwhen 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_tenanton, a query, bulk
update()/delete()or insert on aMultiTenantMixinmodel with no tenant
context raisesTenantIsolationErrorinstead of reading or writing every
tenant's rows. ORMupdate()/delete()are now tenant-scoped too; they were
not before. - Changing a row's
tenant_idis refused whether or not a tenant is bound
(it used to be checked only inside a tenant context); only anall_tenants()
block may move a row between tenants. - Tenant rules now cover every ORM write path, not only
session.add: an
ORMinsert(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 atenant_context
switch) is refused. tenant_context()nested inall_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
DatabaseStatein the process
no longer switches it off for the first. The Celery worker's session gets the
tenant listeners and the host'smulti_tenantsetting too (#371). - New
MissingTenantError(aTenantIsolationError) for "no tenant bound". - Tenant and soft-delete criteria reach join targets, subqueries (including a
bare Coreexists().where(...)),count().select_from()and top-level Core
statements onModel.__table__(#332). Behaviour change: a join or count
over a soft-deletable model now excludes trashed rows, as a plainselect
already did;include_deleted=Truestill 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). Thetenantsmodule resolves a
tenant from the subdomain (subdomain_base), anonymous visitors included
(#363).- Request-path performance (Postgres load test,
docs/perf/2026-10-08-postgres-loadtest.md).
Route matching skips any included router whose routes cannot match the path:
FastAPI ≥ 0.140 regex-tested nearly all ~190 routes per request, ~30% of a
cheap request's CPU (/health1.98 → 1.46 ms). The guards are built at
startup, which also takes FastAPI's lazy per-route build off the first
request after boot (360 → 32 ms). GZip runs at level 5 instead of 9: about
1% larger output for 2.4× less CPU. Trivial sync FastAPI dependencies
(get_permission_registry,get_feature_flag_registry, thefile_storage,
tenantsandusersaccessors) are nowasync, so they no longer
take a threadpool round-trip./admin/userscounts its status cards in one
query instead of three. SetupMiddlewarerefreshes its cached verdict single-flight. When the 5 s
TTL lapsed under load, every in-flight request ran the setup steps itself,
each checking out a pooled connection. Behind a saturated pool those
checkouts queued and fedQueuePool limit … reachedtimeouts. The expired
complete verdict now keeps answering while one refresh runs.
Security
- An ORM select that reads a raw
Model.__table__(or analiased()entity's
table) only in its WHERE clause now gets the tenant filter, and strict mode
refuses it without a tenant; such a select could read another tenant's link
rows (#559, extends #506). - Tenant and soft-delete filters now cover Core statements that used to leak
other tenants' or deleted rows (#506): outer and FULL joins over
Model.__table__, table aliases, subqueries, CTEs, scalar/correlated
subqueries,exists()over joins or in ON clauses,union/intersect/
except, tables joined into UPDATE/DELETE, andinsert().from_select()
sources. Rawtext()SQL remains unfiltered by design. smpy newno longer writes generated secrets into the committed
.env.example(#441).SM_SECRET_KEYand the users token secrets go to a
gitignored.env(created if absent; an existing one only gains missing
keys), and.env.examplekeeps# KEY=placeholders. Apps scaffolded
earlier should rotate those secrets if.env.examplewas committed.- The scaffold's
docker-compose.ymlno longer publishes Postgres on
0.0.0.0:5432withpostgres/postgreswhile the app runs in production
mode (#444). The password comes fromPOSTGRES_PASSWORD(generated into
.env; compose refuses to start without it), and Postgres and Redis bind
127.0.0.1only. - 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
tenantsresolver it selects among the user's own memberships.
Fixed
-
A stored host setting that parses but fails validation (or an unrecognised
boolean like"tru") is dropped with a WARNING instead of crashing boot or
reading as false (#497); one failing module settings read no longer resets
that module or poisons the others' reads. -
Every
HostSettingsfieldcreate_appreads is marked restart-required, so
the settings editor says so (#499). -
An established install whose setup check raises no longer drops into the
first-run wizard. -
The admin mobile sidebar drawer closes on Escape, traps focus and is a
labelled dialog while open (#554). -
Docs describe the real migration graph (two roots, four heads, inherited
labels), pinned by a test (#503). -
file_storagebrowse page: a caller withoutfile_storage.managesees their
own usage and no tenant quota, instead of the tenant-wide total; the
authenticated download now carries the same sandbox CSP as public serving. -
Settings editor: saving a field's default (clearing an optional field
included) removes its override row instead of storing it, so the field
reads "default" again; a field's validation error sits on its own row below
the control instead of wrapping beside it. -
A runtime permission source that raises is no longer cached as empty until
invalidated: it contributes nothing for 30 seconds (one logged traceback),
then is retried;invalidate_sourceretries it at once (#517). -
background_tasks: a prefork Celery child no longer reuses the sync engine
(and Postgres connections) built in the parent (#532). -
Settings validation:
file_storage.quota_bytesmust be positive (and at most
2^53-1), andbackground_tasks.task_time_limitmust exceed
task_soft_time_limitwhen both are set (#548). With the hard limit set by
env, an offending stored soft limit is dropped (and logged) at boot instead
of discarding every background_tasks override and stopping the worker. -
Settings editor: list/dict fields are edited and stored as JSON, and
X | Nonefields as their real type with empty meaning unset (#487).
Secrets inside such fields are stored intact, and any field declared with
SecretStr/SecretBytesis masked on every read path whatever its name. -
Module settings classes without an
env_prefixare no longer pinned by
unrelated bare env vars (HOME,USER, ...) (#486). -
S3 presigned downloads keep the original filename and content type, and the
streamed download no longer 500s on a non-Latin-1 filename (#489). -
The scaffolded
run_worker.pyhydrates DB-backedbackground_tasks
settings like the repo's own worker, via
background_tasks.runner.build_runner_celery()(#490). -
A failing verification email no longer leaves a new signup without its role
(#488). -
The Celery worker/beat process now hydrates DB-backed
background_tasks
settings at boot (it used to read the environment only, so admin edits reached
the web process but never the worker). -
enqueue_after_commitfrom a request that writes nothing else no longer loses
the publish to a read-only rollback; a retry whose send fails after commit is
markedfailedinstead of leftpending; a sub-secondstuck_afterno longer
truncates to 0; a416response no longer carriesCache-Control. -
POST /i18n/set-localeis mounted bycreate_app(#462), so the language
switcher works in scaffolded apps instead of posting to a 404. -
The Register page shows field-level errors (weak password, existing
account, invalid email) under the right field, translated, and blocks
submit until the password meets the server's policy (#465). -
Auth screens drop the framework "python" caption once branding sets an app
name, and only mention Keycloak SSO when Keycloak is installed (#466). -
@simple-module-py/uiloads@tailwindcss/typography, soprosecontent
(e.g. markdown widgets) is styled in scaffolded hosts (#474). -
Scaffolded apps render the real Error page (layout, per-status copy,
sign-in link, correlation id) instead of a bare "Something went wrong"
(#475). -
Timezone-aware timestamp columns read back aware on SQLite (#468). Columns
declaredsa_type=DateTime(timezone=True)(theAuditMixin/SoftDeleteMixin
timestamps, audit log, users, permissions, tenant invitations) came back
naive, so the API sent them with no offset and browsers showed UTC as local
time. Newsimple_module_db.AwareDateTimereplaces that column type; it
migrates assa.DateTime(timezone=True), so no schema change. -
Background-task Retry sends one request per execution while one is in
flight (#467); a burst of confirm clicks no longer queues duplicate retries. -
The background-tasks list shows its plain empty state when no executions
exist at all, instead of claiming some exist outside the search (#469). -
Deleting files another tab already deleted says so, instead of a success
toast reading "0 files deleted" (#470). -
smpy's workspace-member hint prints./modules/...for a module inside
the host directory, so the suggestedsmpy addreads it as a path, not a
PyPI name (#471). -
Scaffolded compose apps require a URL-safe
POSTGRES_PASSWORD: compose puts
it unencoded intoSM_DATABASE_URL, somake docker-upnow refuses other
characters with a clear message (#472). -
S3Backend.getstreams objects in 64 KiB reads instead of buffering the
whole object in memory (#453). It is now awaited like the filesystem
backend, which also fixes S3 thumbnails and the streamed public-file path
(they raisedTypeError), and a missing object answers404, not500. -
Admin task retries publish to the broker only after the new
TaskExecutionrows commit (#452); a rolled-back request no longer leaves a
queued task with no execution row. -
POST /api/users/auth/tokenpersists the upgraded password hash, as the
browser login already did (#457), so bcrypt hashes imported from another
system migrate to Argon2 on API/mobile sign-in too. -
Scaffolded apps regenerate
keys.generated.tsin development (#449): when
packages/i18n/srcis absent the host writes into the installed
@simple-module-py/i18npackage, merging the keys it already ships. -
Scaffolded
make killstops only this app's ports (API_PORT, and
VITE_PORTread fromSM_VITE_DEV_URL) instead ofpkill-ing every
uvicorn and vite on the machine, vitest runs included (#461). -
render_itemrenders sqlmodel>=0.0.48'sUTCDateTimeas
sa.DateTime(timezone=True)and addsimport sqlmodelfor any other
sqlmodel type, so an initial autogenerate no longer emits a migration that
dies withNameError: name 'sqlmodel' is not defined(#440). -
smpy add <path>for a module inside the host's uv workspace writes
{ workspace = true }and an unpinned dependency instead of a path source uv
rejects (#442). A relative path missing under--pathfalls back to the cwd,
andsmpy create-moduleinside a workspace prints thesmpy addcommand. -
Scaffold hygiene (#443): templates name the
smpyCLI (notsm),uv.lock
is committed rather than gitignored, and the Postgres database name is
snake_case (demo_app, notdemo-app). -
Scaffolded dev groups floor
playwright>=1.45; without it uv resolved
playwright 1.18 and everypytestrun failed at plugin load (#445). -
/admin/background-tasksreturned 500 on Postgres (#413):TaskExecution's
timestamps were declared naive although the columns aretimestamptzsince
e5f2a8c1d7b3. This was already fixed onmainby #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 orfetch()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 withoutof_type). A raw-table
target or asecondaryrelationship stays filtered inWHERE, 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'sevent.contains, which keys onid().
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-pagesemits@sourcelines for subdirectories of wheel modules (#419);
uv's.venv/.gitignoremade Tailwind skip them, so their utility classes
were missing from the built CSS. -
Brand foreground ink follows the brand colour (#420):
deriveBrandRampsets
--primary-foregroundand--sidebar-primary-foregroundby 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/adminfor a signed-in admin
render inside the admin shell. -
TenantMiddlewaremerges everyVaryresponse line and leaves aVary: *
response untouched (#423), andtenant_sourceisNonewhenever 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
new document paints a blank white body until the bundle has booted. Admin
screens were never affected because the shell navigates with Inertia's
<Link>, but pagebuilder widgets and their markdown/rich-text fields render
author-entered URLs as plain<a href>, and there is no component to swap for
a<Link>when the anchor comes out of a markdown parser. A downstream site
measured ~330ms of blank viewport per click on a throttled connection.
@simple-module-py/uinow exportsstartSpaLinkInterception(), a delegated
click handler that routes same-origin page links through Inertia whatever
produced the anchor; thesmpy newapp template calls it. It deliberately
leaves alone anything Inertia cannot render — other origins, non-http schemes,
paths that look like a file, in-page anchors,download/target/
rel="external"/data-native-link, and anchors inside a Puck editor surface
— and falls back to a hard navigation if a visit returns without an
x-inertiaheader, so a media download can never be replaced by an error
modal. Existing apps must add the one-line call to their own
host/client_app/app.tsx, which is scaffold output and so is not upgraded
for them. -
smpy gen-pagesnow emits module stylesheet@importlines as absolute
paths instead of#module/<pkg>alias specifiers. The alias only resolved
if the host'svite.config.tsdefined a matchingresolve.alias— but that
file is scaffold output, written into an app once and then owned and edited
there, so it is versioned independently of these Python packages. Upgrading
simple_module_*0.0.26 → 0.0.27 therefore brokevite buildin every app
scaffolded earlier, failing withCan't resolve '#module/<pkg>/styles.css'—
naming a specifier that appears nowhere in the app's own sources.modules.generated.cssis now self-contained: it resolves under any
vite.config.ts, with no alias configured at all, exactly as the@source
lines in the same file already did. No host action is required — upgrade
and re-rungen-pages. The scaffold template still defines the
#module/<pkg>alias for hand-written imports, but nothing generated depends
on it any more (GH issue #253).
Added
-
A module can now import another module's TS/TSX by npm package name:
import x from '@simple-module-py/pagebuilder/components/blockRegistry'.
Nothing in Node's own resolution made this work — a wheel-installed module is
not an npm workspace member, so it never lands innode_modulesat all;
a workspace member is symlinked, but onto the source-tree module root, one
level above the Python package, so subpaths landed somewhere nonexistent.gen-pagesnow records each module'snpm_nameinmodules.assets.json,
and the host aliases it onto the module's Python package directory. That
anchor is forced, not chosen: a wheel shipssite-packages/<pkg>/**and
nothing above it, so the module root does not survive installation and the
package directory is the only anchor both layouts share. The practical
consequence is that the subpath is relative to the package:import x from '@simple-module-py/foo/components/Widget'; // ✅ both layouts import x from '@simple-module-py/foo/foo/components/Widget'; // ❌ workspace-only
If you previously hand-rolled this alias against the module root, drop the
duplicated path segment. Existing hosts need avite.config.tschange —
unlike the CSS fix above, the import lives in module source rather than a
generated file, so it cannot be made self-contained. In the loop over
modules.assets.jsonentries, add:if (entry.npm_name) { moduleAliases.push({ find: entry.npm_name, replacement: entry.package }); }
and skip those names when collecting
optimizeDeps.include— they resolve to
source directories, not to pre-bundlable packages (GH issue #253).