You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 162072a
Browse filesBrowse the repository at this point in the historyBrowse files
Typed API client (openapi-fetch + generated types) and typed Inertia page props (#504#505) (#576)
* docs: batch 4 design (typed API client, typed page props)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* 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
* Typed page props: usePageProps, SharedProps registered with Inertia, remove double casts (#505)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* feat(ui): typed API client — createApi, apiResult, apiFetch, useApiErrorToast (#504)
createApi<paths>() wraps openapi-fetch with same-origin credentials,
Accept: application/json and the csrfToken shared prop as X-CSRF-Token on
unsafe methods. apiResult()/apiFetch() normalise to
{ ok, status, data, detail, fieldErrors, error }, network failures as
status 0. openapi-typescript is pinned as a packages/ui devDependency so
it can use TypeScript 5 without displacing the repo's TypeScript 7.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* feat(hosting): csrfToken shared prop for signed-in sessions (#504)
Anonymous pages (and therefore any page a module may mark cacheable)
never carry it, and nothing is minted into their session. API-token
principals render as anonymous and get none either.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* feat(cli): smpy openapi gen-types + gen-api-types / ci-api-types-fresh (#504)
Cuts each module's /api operations (and only the component schemas they
reach) out of the in-memory schema and runs openapi-typescript into
<pkg>/pages/api.generated.ts. Without --module it regenerates modules that
already have the file; it never writes outside the project. Operations
whose JSON success body has no schema are listed as untyped.
ci-api-types-fresh hashes the generated files before/after like
ci-i18n-fresh; it runs in make lint and the js-typecheck CI job.
*.generated.ts is exempt from Biome and the 300-line cap.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* refactor(tenants, api_tokens): typed API client and typed page props (#504)
tenants: 10 fetch calls move to api.* (8 typed, 2 through apiFetch: the
settings module's route and the server-supplied multipart upload URL);
useTenantErrors.describe takes an ApiResult and keeps each call's own
offline copy. apiDetail.ts is gone. api_tokens: postJson becomes typed
api.POST calls that still pin the page's csrf_token prop; the unused
api_url prop is dropped. Pages take typed component args instead of the
usePage() double cast.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix(ui): never send the CSRF token to another origin (#504)
apiFetch and the createApi middleware attached X-CSRF-Token whatever the
destination, so an absolute or protocol-relative URL (or a foreign
baseUrl) would hand the session's token to that origin. The header is now
set only when the request's origin equals location.origin.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* feat(cli): scaffolded modules ship the typed API client (#504)
Module templates (base and framework_repo) add pages/api.ts, a sample
pages/api.generated.ts matching what gen-types emits for the CRUD routes
(pinned by test_cli_gen_types_scaffold), generated DTO types for page
props, and a Browse delete action through api.DELETE. make new-module
regenerates the types once the module is installed; smpy create-module
prints the gen-types command (the module is not importable yet there).
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* docs: typed API client, csrfToken prop, untyped endpoint list (#504)
Untyped /api endpoints (JSON success body without a schema), 15 in all:
AuditLog: GET /api/audit_log/export.csv
Branding: GET /api/branding/logo, /logo-dark, /favicon
FileStorage: GET files/{id}/thumbnail, files/{id}/download,
public/{id}/thumbnail, public/{id}/{filename}, public/{id}
Users: DELETE auth/token; POST auth/forgot-password, auth/reset-password,
auth/request-verify-token; GET auth/{provider}/login, /callback
Details in docs/superpowers/specs/2026-10-22-untyped-endpoints.md.
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* test(cli): keep the scaffold drift test's boot out of the repo (#504)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* test(hosting): strict=False pin follows the shared openapi app builder (#504)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix(ui): csrf token tracker no longer resurrects the embedded token after an anonymous navigation (#504)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* test(ui): csrf test resets use undefined (#504)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* perf(ui): resolve csrf token only for unsafe same-origin requests; cache embedded parse (#504)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* fix: address code review findings (round 1, pass 1)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
* Fix main's new pages/tests for typed-client gates (typed args instead of double casts)
Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
and `useApiErrorToast(fallbackKey)`. `smpy openapi gen-types [--module X]` writes
25
+
each module's `pages/api.generated.ts` from its routes; `make gen-api-types`
26
+
regenerates them and `make ci-api-types-fresh` (in `make lint` and CI) fails on a
27
+
stale one. `*.generated.ts` is exempt from Biome and the 300-line cap. New modules
28
+
ship `pages/api.ts`, the generated types and a Browse page that deletes through
29
+
the client. `TranslationKey` is exported from `@simple-module-py/i18n`.
30
+
-`csrfToken` Inertia shared prop for signed-in sessions (#504), which the API client
31
+
sends as `X-CSRF-Token` to the page's own origin. Anonymous pages never carry it.
20
32
- 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`.
21
33
- 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.
22
34
- 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.
@@ -144,6 +156,15 @@ All notable changes to this project are documented in this file. The format is b
144
156
it, which removes the `users` ↔ `audit_log` import cycle.
145
157
146
158
### Changed
159
+
- tenants and api_tokens pages call their APIs through the typed client and take
160
+
typed component props (#504). **Behaviour change:** the api_tokens pages no longer
161
+
receive the unused `api_url` prop.
162
+
- Typed page props (#505): `@simple-module-py/ui` registers `SharedProps` (now including `i18n`) with
163
+
Inertia, so `usePage().props.auth` is typed, and exports `usePageProps<T>()` for components and
164
+
layouts. Pages take typed component arguments. The `as unknown as` casts in module pages, `packages/ui`
165
+
and `host/client_app` are gone, and `make lint` / CI reject new ones (`ci-check-no-double-cast`)
166
+
unless marked `// cast-ok: <reason>`. `docs/frontend/shared-props.md` and the
167
+
`simple-module-inertia-pages` skill no longer teach the cast.
147
168
-**Behaviour change:** bulk and Core DML writes on audited models now produce
148
169
audit entries (actions `bulk_*`), in requests and Celery workers. Statements
149
170
that set only excluded columns (the outbox, `TaskExecution.heartbeat_at`) are
Copy file name to clipboardExpand all lines: docs/frontend/inertia.md
+64-4Lines changed: 64 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -71,7 +71,7 @@ Inertia sends the request with the `X-Inertia` header and expects an Inertia res
71
71
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:
72
72
73
73
- Change the endpoint to live in `views.py` and return `inertia.render(...)` or a redirect.
74
-
- Or change the page code to use `fetch(...)` for a true JSON call.
74
+
- 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)).
75
75
76
76
## Redirect-after-post
77
77
@@ -129,7 +129,7 @@ Every Inertia response includes:
129
129
}
130
130
```
131
131
132
-
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).
132
+
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).
133
133
134
134
## Frontend runtime setup
135
135
@@ -243,10 +243,70 @@ landed owns its own `app.tsx` and must add the call itself.
243
243
244
244
## CSRF
245
245
246
-
There is no explicit CSRF token middleware. Protection comes from`SameSite=Lax` on the session cookie:
246
+
The baseline is`SameSite=Lax` on the session cookie:
247
247
248
248
- Browsers don't attach the session cookie to **cross-site** POST/PUT/DELETE.
249
249
- A forged form-submit from another origin arrives without authentication, so it's 401/403.
250
250
- Same-site form submissions work normally.
251
251
252
-
This is sufficient because Inertia mutations go through `fetch`, which honors `SameSite`. Raw `fetch()` calls in page code don't need a CSRF header.
252
+
Inertia forms need nothing more. A module that wants defence in depth puts
253
+
`RequiresCsrf` from `simple_module_hosting.csrf` on its API router; the check
254
+
then wants the session's token in an `X-CSRF-Token` header on
255
+
POST/PUT/PATCH/DELETE (safe methods are never checked).
256
+
257
+
Every **signed-in** page carries that token as the `csrfToken` shared prop.
258
+
Anonymous pages never do (nothing is minted into their session, so a public
259
+
page stays cacheable and never hands one visitor's token to the next), and an
260
+
API-token principal renders as anonymous. The typed API client below reads the
261
+
prop and sends the header itself, only to the page's own origin, so page code
262
+
never handles the token. A page may still receive its own token prop (the
263
+
api_tokens pages do) and pin it per call with `headers`.
264
+
265
+
## JSON calls from pages
266
+
267
+
Each module calls its `/api/*` routes through a typed client (#504):
-`export default` is **required** — Inertia's resolver expects a default export.
93
-
- Props are passed from the server; type them explicitly.
94
-
- Use `useT()` from `@simple-module-py/i18n` for translations and `usePage()` (with the `SharedProps` type from `@simple-module-py/ui`) for `auth` / `menus`.
93
+
- Props are passed from the server; type them as the component's argument (`function Browse({ items }: Props)`).
94
+
- 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`.
0 commit comments