From 51b03240669d30e0eeabb24b114f879458f74309 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 10:59:18 +0200 Subject: [PATCH 01/17] docs: batch 4 design (typed API client, typed page props) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../2026-10-22-typed-client-props-design.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-22-typed-client-props-design.md diff --git a/docs/superpowers/specs/2026-10-22-typed-client-props-design.md b/docs/superpowers/specs/2026-10-22-typed-client-props-design.md new file mode 100644 index 00000000..70cd8d03 --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-typed-client-props-design.md @@ -0,0 +1,48 @@ +# Batch 4: typed API client (#504) and typed page props (#505) + +Approved 2026-10-22 as proposed. Baseline: 51 `fetch(` call sites in 36 files; 44 `as unknown as` +casts (29 modules, 10 `packages/ui`, 4 `host/client_app`, 1 setup wizard). + +## #504 Typed API client + +- Runtime: `openapi-fetch` (dependency of `@simple-module-py/ui`); types from `openapi-typescript` + (dev dependency). Wrapper `packages/ui/src/lib/api.ts` (<150 lines, tested): + `createApi()` returns an `openapi-fetch` client whose middleware sets + `credentials: 'same-origin'`, `Accept: application/json`, and `X-CSRF-Token` on + POST/PUT/PATCH/DELETE when a token is known. Results normalise to + `{ ok, status, data, detail, fieldErrors }` (`detail`: string `detail` or null; `fieldErrors`: last + `loc` element → `msg` for 422). Untyped `apiFetch(url, init)` for non-`/api` routes (`/setup/*`) + and multipart uploads. No English strings in the wrapper. +- `useApiErrorToast(fallbackKey)` hook: opt-in toast of `detail ?? t(fallbackKey)`. +- Generated types: committed `/pages/api.generated.ts` per module; module `pages/api.ts` exports + `api = createApi()`. +- Generator: `smpy openapi gen-types [--module X]` (in-memory export filtered by the module tag → + `npx openapi-typescript` → file with a generated header). Make targets `gen-api-types` and + `ci-api-types-fresh` (hash before/after, like `ci-i18n-fresh`), added to `make lint`'s CI + equivalent and `pr.yml`. `**/*.generated.ts` exempt from the file-size cap and Biome formatting. +- Scaffold: module templates (and `_optional/framework_repo`) ship `pages/api.ts` + an initial + generated file and use `api.*` plus typed component args; `smpy` runs `gen-types` after scaffolding. + Update shipped skills `simple-module-inertia-pages` / `simple-module-conventions`. +- CSRF: new shared Inertia prop `csrfToken`, only for authenticated sessions (never on anonymous or + cacheable pages). Server enforcement unchanged (opt-in `RequiresCsrf`). Page-prop tokens in + api_tokens and the setup wizard remain as overrides. +- Migrate in this batch: tenants (11 calls), api_tokens (1). List endpoints without a response type + in the PR. + +## #505 Typed page props + +- `packages/ui/src/hooks/usePageProps.ts`: `usePageProps()` returning + `T & SharedProps & { errors }` (the one justified cast lives here). +- Augment Inertia's `InertiaConfig.sharedPageProps` with `SharedProps` (incl. `i18n`) in + `packages/ui`, so `usePage().props.auth` is typed. +- Pages use typed component args; components/layouts use `usePageProps`. Remove all 44 casts. +- Gate `scripts/check_no_double_cast.py` (`make ci-check-no-double-cast`, in `make lint` and CI): + fails on `as unknown as` in `.ts/.tsx` under module `pages/`, `packages/ui/src`, `host/client_app` + unless the line (or the line above) carries `// cast-ok: `. Tests excluded. +- Fix `docs/frontend/shared-props.md` and `docs/frontend/inertia.md` (CSRF/fetch guidance). + +## Later + +- Batch 4b: users (24 calls), background_tasks, branding, settings, file_storage (split + `Browse.tsx` into `useFileActions` first), setup wizard → `apiFetch`; raw-`fetch(` gate. +- Follow-up issue: DTO-declared page props generated into per-module `PageProps` maps. From 2bca537f0deefd6c8f5d4f26dd51b00aa2c6bf2e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 11:10:37 +0200 Subject: [PATCH 02/17] Gate: reject unmarked 'as unknown as' in shipped TS; docs and skill stop teaching the cast (#505) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .github/workflows/pr.yml | 2 + CHANGELOG.md | 6 ++ Makefile | 9 +- docs/frontend/inertia.md | 2 +- docs/frontend/pages.md | 4 +- docs/frontend/shared-props.md | 37 +++++--- scripts/check_no_double_cast.py | 95 +++++++++++++++++++++ scripts/tests/test_check_no_double_cast.py | 55 ++++++++++++ skills/simple-module-inertia-pages/SKILL.md | 2 + 9 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 scripts/check_no_double_cast.py create mode 100644 scripts/tests/test_check_no_double_cast.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7b07d7e8..675085bc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbacc79..3fa32deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,12 @@ 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 +- Typed page props (#505): `@simple-module-py/ui` registers `SharedProps` (now including `i18n`) with + Inertia, so `usePage().props.auth` is typed, and exports `usePageProps()` 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: `. `docs/frontend/shared-props.md` and the + `simple-module-inertia-pages` skill no longer teach the cast. - **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.py` also bumps every `modules/*/package.json`; all diff --git a/Makefile b/Makefile index 69eacca7..be33ee7a 100644 --- a/Makefile +++ b/Makefile @@ -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-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 ?= @@ -124,7 +124,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-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: @@ -200,6 +200,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: `; 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: diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md index 424d0ec9..4744afa2 100644 --- a/docs/frontend/inertia.md +++ b/docs/frontend/inertia.md @@ -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 diff --git a/docs/frontend/pages.md b/docs/frontend/pages.md index 7f64a5c8..9a22a171 100644 --- a/docs/frontend/pages.md +++ b/docs/frontend/pages.md @@ -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()` from `@simple-module-py/ui`. ## Subdirectories diff --git a/docs/frontend/shared-props.md b/docs/frontend/shared-props.md index eeae4de4..1cd0da6d 100644 --- a/docs/frontend/shared-props.md +++ b/docs/frontend/shared-props.md @@ -5,7 +5,8 @@ Every Inertia response carries a set of shared props attached by `InertiaLayoutD ## The contract ```ts -// The exported SharedProps type (packages/ui/src/types.ts) covers auth + menus: +// The exported SharedProps type (packages/ui/src/types.ts) covers auth + menus + i18n, plus +// the optional signup, homeUrl, demo and branding props: interface SharedProps { auth: { user: { name: string; email: string; roles: string[] } | null; @@ -21,7 +22,7 @@ interface SharedProps { } // Every Inertia response also carries an `i18n` block (read via usePage().props.i18n): -interface I18nSharedProps { +interface I18nSharedProps { // SharedProps['i18n'] locale: string; supportedLocales: string[]; messages: Record | null; // null on Inertia XHR partials when the locale is unchanged @@ -40,20 +41,32 @@ Then it stores the result on `request.state.inertia_shared`, which `get_inertia` ## Accessing from a page -Read `auth` / `menus` via Inertia's `usePage` and the exported `SharedProps` type; use `useT` from `@simple-module-py/i18n` for translations: +`@simple-module-py/ui` registers `SharedProps` with Inertia (`InertiaConfig.sharedPageProps`), so `usePage().props` is already typed: no generic, no cast. A page gets its own props as typed component arguments; a component or layout that needs the page's own props as well uses `usePageProps()`, which returns `T & SharedProps & { errors }`. Use `useT` from `@simple-module-py/i18n` for translations: ```tsx -import { usePage } from "@inertiajs/react"; import { useT } from "@simple-module-py/i18n"; -import type { SharedProps } from "@simple-module-py/ui"; +import { usePageProps } from "@simple-module-py/ui"; -export default function Browse() { - const { auth, menus } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; +interface Props { + orders: Order[]; +} + +// A page: own props as arguments, shared ones from usePageProps. +export default function Browse({ orders }: Props) { + const { auth, menus } = usePageProps(); const { t } = useT(); // auth.user, auth.permissions, menus.sidebar, ... } + +// A component that cannot receive props as arguments: +function OrderCount() { + const { orders } = usePageProps(); + return {orders.length}; +} ``` +Do not write `usePage<{ props: X }>().props as unknown as X`. The `usePage` generic is the props type itself, not `{ props: X }`, so that form was already wrong, and the cast hid it. `make lint` (`scripts/check_no_double_cast.py`) rejects `as unknown as` in pages, components, `packages/ui` and `host/client_app` unless the line (or the one above) carries `// cast-ok: `. + The layouts in `@simple-module-py/ui` (`SidebarLayout`, `PublicLayout`, …) read these props for you, so most pages don't touch `usePage` directly. ## `auth.user` — the principal serializer @@ -99,14 +112,14 @@ registry.add( `menu_registry.get_for_user(is_authenticated=..., roles=...)` filters by `requires_auth` and `roles` before sending — users without a matching role never see the item. -Render in a layout via `usePage` and the `SharedProps` type: +Render in a layout via `usePageProps`: ```tsx -import { usePage } from "@inertiajs/react"; -import type { MenuItem, SharedProps } from "@simple-module-py/ui"; +import type { MenuItem } from "@simple-module-py/ui"; +import { usePageProps } from "@simple-module-py/ui"; export function Sidebar() { - const { menus } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const { menus } = usePageProps(); return (