Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
51b0324
docs: batch 4 design (typed API client, typed page props)
antosubash Oct 22, 2026
2bca537
Gate: reject unmarked 'as unknown as' in shipped TS; docs and skill s…
antosubash Oct 22, 2026
51a34e5
Typed page props: usePageProps, SharedProps registered with Inertia, …
antosubash Oct 22, 2026
2847db5
feat(ui): typed API client — createApi, apiResult, apiFetch, useApiEr…
antosubash Oct 22, 2026
e24deee
feat(hosting): csrfToken shared prop for signed-in sessions (#504)
antosubash Oct 22, 2026
26195c5
feat(cli): smpy openapi gen-types + gen-api-types / ci-api-types-fres…
antosubash Oct 22, 2026
fb8e4ff
refactor(tenants, api_tokens): typed API client and typed page props …
antosubash Oct 22, 2026
cb8aba6
fix(ui): never send the CSRF token to another origin (#504)
antosubash Oct 22, 2026
6fd9f6c
feat(cli): scaffolded modules ship the typed API client (#504)
antosubash Oct 22, 2026
4f9c714
docs: typed API client, csrfToken prop, untyped endpoint list (#504)
antosubash Oct 22, 2026
c72bae5
test(cli): keep the scaffold drift test's boot out of the repo (#504)
antosubash Oct 22, 2026
9924b72
test(hosting): strict=False pin follows the shared openapi app builde…
antosubash Oct 22, 2026
1e37fc4
Merge b4-casts: typed page props (#505) with typed client (#504)
antosubash Oct 22, 2026
47bc20d
fix(ui): csrf token tracker no longer resurrects the embedded token a…
antosubash Oct 22, 2026
8e9e4d7
test(ui): csrf test resets use undefined (#504)
antosubash Oct 22, 2026
74f36bc
perf(ui): resolve csrf token only for unsafe same-origin requests; ca…
antosubash Oct 22, 2026
a1c23b3
fix: address code review findings (round 1, pass 1)
antosubash Oct 22, 2026
daeff56
Merge origin/main into feat/typed-client-b4
antosubash Oct 22, 2026
6cdaf19
Fix main's new pages/tests for typed-client gates (typed args instead…
antosubash Oct 22, 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
5 changes: 5 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ jobs:
- run: make ci-doc-paths
# Fixed sleeps in tests are timing bets; poll with simple_module_test.wait_for.
- run: make ci-check-test-sleeps
# `as unknown as` switches the type checker off; use typed page args / usePageProps.
- run: make ci-check-no-double-cast
# The checked-in typed-key files must match what the installed modules'
# catalogs emit. Catches a catalog edit committed without regenerating, and
# (with the namespace guard in gen_i18n.py) a regeneration that dropped keys.
Expand Down Expand Up @@ -170,6 +172,9 @@ jobs:
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
- run: make install-py
- run: make install-js
# Committed pages/api.generated.ts must match the routes (#504); needs
# both the Python app (schema) and node_modules (openapi-typescript).
- run: make ci-api-types-fresh
- run: make ci-js-typecheck

js-build:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ 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
- Typed API client (#504). `@simple-module-py/ui` ships `createApi<paths>()` (an
`openapi-fetch` client with same-origin credentials, `Accept: application/json` and
the CSRF header), `apiResult()` / `apiFetch()` normalising every call to
`{ ok, status, data, detail, fieldErrors, error }` (network failure: `status: 0`),
and `useApiErrorToast(fallbackKey)`. `smpy openapi gen-types [--module X]` writes
each module's `pages/api.generated.ts` from its routes; `make gen-api-types`
regenerates them and `make ci-api-types-fresh` (in `make lint` and CI) fails on a
stale one. `*.generated.ts` is exempt from Biome and the 300-line cap. New modules
ship `pages/api.ts`, the generated types and a Browse page that deletes through
the client. `TranslationKey` is exported from `@simple-module-py/i18n`.
- `csrfToken` Inertia shared prop for signed-in sessions (#504), which the API client
sends as `X-CSRF-Token` to the page's own origin. Anonymous pages never carry it.
- Resource-scoped access (#526): `simple_module_core.resources` (`ResourcePolicy` with `filter`/`can`, `ResourcePrincipal`, `ResourceType`, `ResourceTypeRegistry`) and a new `register_resource_types` hook (read at `app.state.sm.resource_types`); `simple_module_db.resource_policy.OwnedResourcePolicy` (owner OR public-for-view OR an injected `share_exists`, with a keyword-required bypass permission) so lists and `paginate()` 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 when `permission=` is set. See `docs/framework/resource-access.md`.
- New optional `sharing` module (`simple_module_sharing`, #526): tenant-scoped `sharing_resource_share` table (migration `f733c7938fd1`, a new root and head labelled `sharing`), `share_exists_for(...)` for `OwnedResourcePolicy`, grant/revoke/list API under `/api/sharing/shares` (validates the resource type, the granter's `share` right 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 seam `simple_module_core.tenancy.tenant_member_ids` / `app.state.tenant_members`, published by `tenants`; expiry checked in SQL; revoke hard-deletes) and an admin page at `/admin/sharing/` (en + de). **Release note:** register a PyPI pending publisher for `simple_module_sharing` before 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 `_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 `<script>`. New `public_base_url` host setting (DB-backed, read per request, no restart) builds canonical / `og:url`; the request `Host` is never used. Both `index.html` templates updated. Body SSR remains open in #535.
Expand Down Expand Up @@ -144,6 +156,15 @@ All notable changes to this project are documented in this file. The format is b
it, which removes the `users` ↔ `audit_log` import cycle.

### Changed
- 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 unused `api_url` prop.
- Typed page props (#505): `@simple-module-py/ui` registers `SharedProps` (now including `i18n`) with
Inertia, so `usePage().props.auth` is typed, and exports `usePageProps<T>()` for components and
layouts. Pages take typed component arguments. The `as unknown as` casts in module pages, `packages/ui`
and `host/client_app` are gone, and `make lint` / CI reject new ones (`ci-check-no-double-cast`)
unless marked `// cast-ok: <reason>`. `docs/frontend/shared-props.md` and the
`simple-module-inertia-pages` skill no longer teach the cast.
- **Behaviour change:** bulk and Core DML writes on audited models now produce
audit entries (actions `bulk_*`), in requests and Celery workers. Statements
that set only excluded columns (the outbox, `TaskExecution.heartbeat_at`) are
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Everything goes through `make`. Full list: `docs/reference/make-commands.md`.
| `make migrations-roundtrip-pg` | Postgres portability gate (upgrade → downgrade base → upgrade → `alembic check`); CI runs it |
| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` (gitignored; needed before `tsc`/Vite) |
| `make gen-i18n` | Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` (committed; CI diffs them) |
| `make gen-api-types` | Regenerate each module's `pages/api.generated.ts` from its routes (committed; CI diffs via `ci-api-types-fresh`) |
| `make new-module name=<name>` | Scaffold a module package (see § Adding a module for what it does *not* do) |
| `make openapi` | Export `openapi.json` for typed clients |
| `make worker` / `make beat` | Celery worker / beat for `background_tasks` |
Expand Down Expand Up @@ -85,6 +86,9 @@ Run what CI runs, in this order; a red push costs a review cycle.
drop a namespace (an unsynced venv looks like a deleted module);
`make gen-i18n ARGS=--allow-removals` is only for a module you really
removed or a deliberate `SM_MODULES_ENABLED` subset.
Changed an endpoint's response shape in a module that has `pages/api.generated.ts`?
`make gen-api-types` and commit it. Don't write `as unknown as` in shipped
TS (`ci-check-no-double-cast`); mark an unavoidable one `// cast-ok: <reason>`.
5. Added a module, a table or a migration? Also run `make doctor`, and review
the revision per `docs/module-authoring.md` § Reviewing a revision
autogenerated on SQLite (boolean `server_default` as `sa.true()/sa.false()`,
Expand All @@ -97,9 +101,9 @@ Run what CI runs, in this order; a red push costs a review cycle.
changes). Every feature/fix PR in recent history does this.

CI (`.github/workflows/pr.yml`, aggregate job `pr-checks`): python-lint (includes
`make ci-check-metadata` and the `make ci-i18n-fresh` diff), python-typecheck,
`make ci-check-metadata` and the `make ci-i18n-fresh` diff and `make ci-check-no-double-cast`), python-typecheck,
python-tests (Redis service), js-tests, js-lint (`biome ci --error-on-warnings`,
plus the untranslated check), js-typecheck, js-build (`make ci-css-build`),
plus the untranslated check), js-typecheck (plus `make ci-api-types-fresh`), js-build (`make ci-css-build`),
migrations-roundtrip-pg, doctor (`make ci-doctor`: migrate a throwaway SQLite DB,
then `make doctor`), e2e-smoke, perf-guards, file-size-check, package-build. CI
calls the same `make ci-*` targets as `make lint`, with `UV_SYNC_FLAGS=--locked`
Expand Down
27 changes: 25 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-css-build ci-doctor ci-build-packages worker beat worker-docker
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-api-types-fresh gen-api-types ci-css-build ci-doctor ci-build-packages worker beat worker-docker

# UV_SYNC_FLAGS is empty locally; CI passes --locked so a stale uv.lock fails the build.
UV_SYNC_FLAGS ?=
Expand Down Expand Up @@ -30,6 +30,11 @@ dev-ui:
openapi:
uv run --project host smpy openapi export --out openapi.json

# Regenerate <pkg>/pages/api.generated.ts for modules that have one (#504);
# start a new module's with ARGS="--module <name>".
gen-api-types:
uv run --project host smpy openapi gen-types $(ARGS)

# Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} from installed modules.
gen-pages:
uv run --project host smpy host gen-pages --host-dir=host/client_app
Expand Down Expand Up @@ -124,7 +129,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H
loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph
scripts/loadtest_memray.sh $(LOCUST_ARGS)

lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-api-types-fresh ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated

# Kept granular so pr.yml can run them in parallel.
ci-python-lint:
Expand Down Expand Up @@ -184,6 +189,17 @@ ci-i18n-fresh:
exit 1; \
fi

# Same idea for the typed API client (#504): every module that opted in with a
# committed <pkg>/pages/api.generated.ts must match what its routes emit now.
ci-api-types-fresh:
@before=$$(find modules -path '*/pages/api.generated.ts' -type f | sort | xargs -r cat | sha256sum); \
uv run --project host smpy openapi gen-types || exit 1; \
after=$$(find modules -path '*/pages/api.generated.ts' -type f | sort | xargs -r cat | sha256sum); \
if [ "$$before" != "$$after" ]; then \
echo "pages/api.generated.ts was stale and has been regenerated: run 'make gen-api-types' and commit the result"; \
exit 1; \
fi

# Drives a real gen-pages + vite build to prove module CSS and cross-module npm
# imports resolve. Needs node_modules; slow and rewrites generated files, so it
# is not part of `lint`.
Expand All @@ -200,6 +216,11 @@ ci-check-file-size:
ci-check-test-sleeps:
uv run python scripts/check_test_sleeps.py

# Fail on `as unknown as` in shipped TS (pages, components, packages/ui, host client)
# without `// cast-ok: <reason>`; use typed page args or usePageProps instead.
ci-check-no-double-cast:
uv run python scripts/check_no_double_cast.py

# Enforce that permissions, role names, Inertia page ids, and module dependency
# names are declared as named constants rather than hardcoded string literals.
ci-check-hardcoded-strings:
Expand Down Expand Up @@ -268,6 +289,8 @@ new-module: ## Scaffold a new module (usage: make new-module nam
uv sync --all-packages
npm install
$(MAKE) gen-i18n
@# Replace the scaffold's sample types with what the module's routes emit.
-uv run --project host smpy openapi gen-types --module $(name)

# Kill dev servers
kill:
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"framework/hosting/tsconfig.json",
"!.claude",
"!host/client_app/modules.generated.ts",
"!**/*.generated.ts",
"!host/client_app/modules.manifest.json",
"!host/client_app/modules.generated.css",
"!host/client_app/modules.assets.json"
Expand Down
68 changes: 64 additions & 4 deletions docs/frontend/inertia.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Inertia sends the request with the `X-Inertia` header and expects an Inertia res
This is exactly what diagnostic `SM018` warns about: an `api.py` route that returns JSON, called from a page via `router.post/patch/put/delete`. Either:

- Change the endpoint to live in `views.py` and return `inertia.render(...)` or a redirect.
- Or change the page code to use `fetch(...)` for a true JSON call.
- Or change the page code to call the endpoint through the module's typed API client (see [JSON calls from pages](#json-calls-from-pages)).

## Redirect-after-post

Expand Down Expand Up @@ -129,7 +129,7 @@ Every Inertia response includes:
}
```

Accessed from any page via `usePage().props.auth` etc. (the exported `SharedProps` type from `@simple-module-py/ui` covers `auth` + `menus`); translations come from `useT()` in `@simple-module-py/i18n`. See [Shared props & layout](/frontend/shared-props).
Accessed from any page via `usePage().props.auth` etc. (typed globally from `SharedProps` in `@simple-module-py/ui`, which covers `auth`, `menus` and `i18n`); translations come from `useT()` in `@simple-module-py/i18n`. See [Shared props & layout](/frontend/shared-props).

## Frontend runtime setup

Expand Down Expand Up @@ -243,10 +243,70 @@ landed owns its own `app.tsx` and must add the call itself.

## CSRF

There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie:
The baseline is `SameSite=Lax` on the session cookie:

- Browsers don't attach the session cookie to **cross-site** POST/PUT/DELETE.
- A forged form-submit from another origin arrives without authentication, so it's 401/403.
- Same-site form submissions work normally.

This is sufficient because Inertia mutations go through `fetch`, which honors `SameSite`. Raw `fetch()` calls in page code don't need a CSRF header.
Inertia forms need nothing more. A module that wants defence in depth puts
`RequiresCsrf` from `simple_module_hosting.csrf` on its API router; the check
then wants the session's token in an `X-CSRF-Token` header on
POST/PUT/PATCH/DELETE (safe methods are never checked).

Every **signed-in** page carries that token as the `csrfToken` shared prop.
Anonymous pages never do (nothing is minted into their session, so a public
page stays cacheable and never hands one visitor's token to the next), and an
API-token principal renders as anonymous. The typed API client below reads the
prop and sends the header itself, only to the page's own origin, so page code
never handles the token. A page may still receive its own token prop (the
api_tokens pages do) and pin it per call with `headers`.

## JSON calls from pages

Each module calls its `/api/*` routes through a typed client (#504):

```ts
// <pkg>/pages/api.ts
import { createApi } from '@simple-module-py/ui/lib/api';
import type { paths } from './api.generated';

export const api = createApi<paths>();
```

```tsx
import { apiResult } from '@simple-module-py/ui/lib/api';
import { api } from './api';

const result = await apiResult(
api.POST('/api/tenants/{tenant_id}/switch', { params: { path: { tenant_id: id } } }),
);
if (!result.ok) toast.error(result.detail ?? t(keys.tenants.errors.generic));
```

- `createApi<paths>()` is an [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/)
client: paths, parameters, bodies and responses are checked against the
module's routes. It sends `credentials: 'same-origin'`, `Accept:
application/json` and the `csrfToken` prop.
- `apiResult(call)` normalises any call to `{ ok, status, data, detail,
fieldErrors, error }`. `detail` is the server's string `detail` (a message or
a stable error code), `fieldErrors` maps each 422 error's last `loc` element
to its `msg`, `error` is the raw error body (for codes that carry more, like
a limit), and a request that never reached the server is `status: 0` instead
of a thrown exception. The wrapper holds no English text.
- `useApiErrorToast(fallbackKey)` (`@simple-module-py/ui/hooks/use-api-error-toast`)
returns a function that toasts `detail ?? t(fallbackKey)` for a failed result.
- `apiFetch<T>(url, { method, json })` is the untyped twin with the same
conventions and result shape, for routes outside the module's schema (another
module's route, a server-supplied URL, `/setup/*`) and multipart uploads.

`pages/api.generated.ts` is generated, committed and never edited: run
`smpy openapi gen-types --module <pkg>` (in the framework repo `make
gen-api-types`, which regenerates every module that has the file) after
changing a route or DTO. It needs Node: a local `openapi-typescript` is used
when installed, otherwise `npx` fetches the pinned version. A route without
`response_model` or a return annotation generates an `unknown` body, and the
command lists it. CI fails when a committed file is stale
(`make ci-api-types-fresh`). New modules from `smpy create-module` /
`make new-module` ship the client, the generated file and a Browse page that
deletes through it.
4 changes: 2 additions & 2 deletions docs/frontend/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ export default function Browse({ orders }: BrowseProps) {
```

- `export default` is **required** — Inertia's resolver expects a default export.
- Props are passed from the server; type them explicitly.
- Use `useT()` from `@simple-module-py/i18n` for translations and `usePage()` (with the `SharedProps` type from `@simple-module-py/ui`) for `auth` / `menus`.
- Props are passed from the server; type them as the component's argument (`function Browse({ items }: Props)`).
- Use `useT()` from `@simple-module-py/i18n` for translations. `usePage().props.auth` / `.menus` are already typed (shared props); a component that also needs the page's own props calls `usePageProps<Props>()` from `@simple-module-py/ui`.

## Subdirectories

Expand Down
Loading
Loading