From c6dfe00f7cbce237ff69aa8e687d199bc201d450 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 21 Oct 2026 21:22:24 +0200 Subject: [PATCH 01/21] docs: features batch 3 design (#557 #540 #535 #541 #526) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../2026-10-21-features-batch-3-design.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-21-features-batch-3-design.md diff --git a/docs/superpowers/specs/2026-10-21-features-batch-3-design.md b/docs/superpowers/specs/2026-10-21-features-batch-3-design.md new file mode 100644 index 000000000..cd1da8b0c --- /dev/null +++ b/docs/superpowers/specs/2026-10-21-features-batch-3-design.md @@ -0,0 +1,70 @@ +# Features batch 3: bulk-write audit, worker images, server-rendered head, SPA mounts, resource access + +Approved 2026-10-21 for #557, #540, #535 (step 1), #541, #526 with the defaults below. + +## #557 Audit bulk / Core / raw DML (framework/db) + +- `simple_module_db/bulk_audit.py`: a `do_orm_execute` listener next to `_mark_dml_written` + (`writes.py`). For UPDATE/DELETE on a mapped, audited table (ORM bulk or Core DML on + `Model.__table__`, resolved via the model registry): prefetch primary keys with the same WHERE and + tenant scope (`include_deleted=True`, limit cap+1), run `execute_state.invoke_statement()`, then + hand records to the engine's `audit_callback`. +- ≤ cap (default 500): one record per id, action `bulk_updated` / `bulk_deleted`, `changes` = SET + values; > cap: one summary row, entity_id `"*"`, `{where_sql (no literals), rowcount, truncated}`. + INSERT: one summary row. Drop `_EXCLUDED_MIXIN_FIELDS` / `__audit_exclude_fields__`; a statement + touching only excluded columns is skipped. Add `heartbeat_at` to `TaskExecution`'s + `__audit_exclude_fields__`. +- `execution_options(sm_audit=False)` opts out; `record_bulk_audit(session, entity_type, action, + ids, changes)` is the documented route for `text()` writes. +- `audit_log` DB setting `refuse_unaudited_dml` (default off): raise on opted-out DML against an + audited model. No migration. An audit failure never breaks the write (callback swallows, logs). + +## #540 Worker images (framework/cli) + +- `smpy docker add-worker --queues --base --apt --extras --modules` writes + `docker/worker-.Dockerfile` (Python-only builder, no Node stage, configurable base, apt + packages, `uv sync --extra`) and `compose.worker-.yml` (queues/includes via + `SM_BG_TASKS_WORKER_QUEUES` / `_INCLUDE_MODULES` and `-Q`), added to `compose.yml` through an + idempotent marker-guarded top-level `include:`. Strict regex validation on every input. +- Docs: heavy deps go in a module optional extra enabled only in the worker image. + +## #535 Server-rendered head — step 1 (framework/inertia, hosting, ui) + +- `simple_module_inertia.Head` (SQLModel DTO): title, description, canonical, robots, `og: dict`, + `twitter: dict`, `json_ld: list[dict]`. `inertia.render(page, props, head=Head(...))`. +- `{% inertia_head %}` renders it into `` (markupsafe-escaped; JSON-LD via a safe dumps that + escapes ``; URLs http(s) only); tags marked so the client head manager replaces them. + Also sent as prop `_head`; `packages/ui` `` re-emits it on client visits. +- `HostSettings.public_base_url` (DB-backed, read per request) builds canonical / `og:url` — never the + request `Host`. Step 2 (Node SSR sidecar) is a separate follow-up; #535 stays open. + +## #541 Legacy SPA mounts (core, hosting, ui) + +- `SpaMount(prefix, dist_dir, index="index.html", auth="required"|"public")` from a new non-hook + override `ModuleBase.spa_mounts()`. +- `hosting/_spa_mount.py`: StaticFiles subclass; GET/HEAD only; missing extension-less path with + `Accept: text/html` → index.html (`no-cache`); missing asset → 404 (never HTML); hashed assets + immutable; no request-built paths. `auth="public"` registers the prefix in `PublicRouteRegistry` + (GET/HEAD). New SM diagnostic: a mount prefix overlapping a router prefix fails boot. +- Client: `startSpaLinkInterception({ exclude })`; prefixes reach the client as a framework shared + prop `nativePrefixes` (SPA registry + `HostSettings.native_link_prefixes`). + +## #526 Resource-scoped access (wave 2) + +- Core `simple_module_core/resources.py`: `ResourcePolicy` Protocol (`filter(principal, action, + stmt)`, `can(principal, action, obj)`), `ResourceTypeRegistry`, hook `register_resource_types`. +- Hosting `resource_deps.py`: `RequiresResource(policy, action, loader, permission=None)` — loads via + `policy.filter`; invisible → 404, visible but not allowed → 403; a token gate only with + `permission=`. +- DB helper `OwnedResourcePolicy(owner_col, visibility_col=None, share_exists=None, bypass_perm=...)` + building `owner==uid OR visibility=='public' OR EXISTS(share)`; explicit bypass permission. +- Optional `modules/sharing`: table `sharing_resource_share` (resource_type, resource_id, + subject_type, subject_id, role, granted_by, expires_at; MultiTenantMixin, AuditMixin; unique incl. + tenant_id; hard delete); grant/revoke validated against the registry and the owning module's + `can("share")`; grantees via UserDirectory; admin page; migration with branch label `sharing`. + Share links deferred. + +## Waves + +Wave 1 parallel: A #557, B #540, C #535 step 1, D #541 (merge C before D: both touch host +settings). Wave 2: E #526 core+hosting, then F sharing module. From a80aaadfffe9272c6fae54e0e33680c144552f59 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 21 Oct 2026 21:30:02 +0200 Subject: [PATCH 02/21] feat(inertia): server-rendered for public pages (#535 step 1) Head DTO + inertia.render(head=), escaped tags with data-inertia keys, _head prop re-emitted via Inertia serverHead, public_base_url host setting (never the request Host), both index.html templates, docs, tests. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 1 + docs/frontend/inertia.md | 43 +++++ docs/reference/env-vars.md | 1 + .../templates/host/client_app/app.tsx | 3 + .../templates/host/templates/index.html | 2 +- .../simple_module_hosting/_inertia_setup.py | 10 +- .../simple_module_hosting/host_settings.py | 20 +++ .../tests/test_server_rendered_head.py | 63 +++++++ .../inertia/simple_module_inertia/__init__.py | 3 + .../inertia/simple_module_inertia/config.py | 5 + .../inertia/simple_module_inertia/head.py | 161 ++++++++++++++++++ .../inertia/simple_module_inertia/inertia.py | 20 ++- .../simple_module_inertia/templating.py | 5 +- framework/inertia/tests/test_adapter_head.py | 149 ++++++++++++++++ host/client_app/app.tsx | 3 + host/templates/index.html | 2 +- packages/ui/src/lib/server-head.test.ts | 19 +++ packages/ui/src/lib/server-head.ts | 14 ++ 18 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 framework/hosting/tests/test_server_rendered_head.py create mode 100644 framework/inertia/simple_module_inertia/head.py create mode 100644 framework/inertia/tests/test_adapter_head.py create mode 100644 packages/ui/src/lib/server-head.test.ts create mode 100644 packages/ui/src/lib/server-head.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bd54379..3a7f71166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project are documented in this file. The format is b - `host/client_app` no longer re-declares `@simple-module-py/ui`'s runtime dependencies, nor `@base-ui/react`, `@radix-ui/react-slot`, `autoprefixer` or `postcss` (nothing loaded them) (#521). ### Added +- Server-rendered `` 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 `_head` prop and re-emitted on client visits via Inertia's `serverHead` (`@simple-module-py/ui/lib/server-head`). URL fields are http(s)-only, JSON-LD cannot close its `` and `