simple-module-hosting 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. simple_module_hosting/__init__.py +7 -0
  2. simple_module_hosting/_error_handlers.py +54 -0
  3. simple_module_hosting/_hydrate_step.py +39 -0
  4. simple_module_hosting/_inertia_setup.py +73 -0
  5. simple_module_hosting/_inertia_shared.py +61 -0
  6. simple_module_hosting/_observability.py +108 -0
  7. simple_module_hosting/_phase_helpers.py +160 -0
  8. simple_module_hosting/app_builder.py +281 -0
  9. simple_module_hosting/bootstrap_settings.py +55 -0
  10. simple_module_hosting/cli.py +292 -0
  11. simple_module_hosting/health.py +79 -0
  12. simple_module_hosting/host_settings.py +33 -0
  13. simple_module_hosting/i18n_deps.py +25 -0
  14. simple_module_hosting/i18n_manifest.py +202 -0
  15. simple_module_hosting/i18n_middleware.py +95 -0
  16. simple_module_hosting/inertia_deps.py +27 -0
  17. simple_module_hosting/inertia_utils.py +31 -0
  18. simple_module_hosting/logging.py +91 -0
  19. simple_module_hosting/manifest.py +250 -0
  20. simple_module_hosting/middleware.py +272 -0
  21. simple_module_hosting/migrations.py +65 -0
  22. simple_module_hosting/permissions.py +75 -0
  23. simple_module_hosting/py.typed +0 -0
  24. simple_module_hosting/redirects.py +45 -0
  25. simple_module_hosting/scaffolding.py +294 -0
  26. simple_module_hosting/settings.py +10 -0
  27. simple_module_hosting/templates/host/.env.example +20 -0
  28. simple_module_hosting/templates/host/.gitignore +19 -0
  29. simple_module_hosting/templates/host/Makefile +24 -0
  30. simple_module_hosting/templates/host/README.md.tpl +59 -0
  31. simple_module_hosting/templates/host/alembic.ini +36 -0
  32. simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  33. simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  34. simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  35. simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  36. simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  37. simple_module_hosting/templates/host/client_app/styles.css +7 -0
  38. simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  39. simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  40. simple_module_hosting/templates/host/main.py +27 -0
  41. simple_module_hosting/templates/host/migrations/env.py +80 -0
  42. simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  43. simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  44. simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  45. simple_module_hosting/templates/host/templates/index.html +12 -0
  46. simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  47. simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  48. simple_module_hosting/templates/module/.gitignore +14 -0
  49. simple_module_hosting/templates/module/README.md.tpl +82 -0
  50. simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  51. simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  52. simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  53. simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  54. simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  55. simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  56. simple_module_hosting/templates/module/package.json.tpl +16 -0
  57. simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  58. simple_module_hosting/templates/module/tests/__init__.py +0 -0
  59. simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  60. simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  61. simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
  62. simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
  63. simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
  64. simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
  65. simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,250 @@
1
+ """Frontend pages manifest + per-module JS dependency discovery.
2
+
3
+ * :func:`write_module_pages_manifest` emits the three generated files Vite
4
+ and Tailwind read at build time:
5
+ - ``modules.manifest.json`` — name -> absolute pages dir
6
+ - ``modules.generated.ts`` — ``import.meta.glob`` per module
7
+ - ``modules.generated.css`` — ``@source`` per wheel-installed module
8
+ * :func:`read_module_package_json` / :func:`collect_module_js_deps` locate
9
+ the per-module ``package.json`` shipped inside wheels (or alongside the
10
+ source for editable installs) and aggregate its ``dependencies`` block.
11
+ Used by the ``sm sync-js-deps`` CLI.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib.resources
17
+ import json
18
+ import logging
19
+ import os
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+ from simple_module_core import ModuleBase, get_module_package_name
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _GENERATED_TS_HEADER = """\
28
+ // AUTO-GENERATED by simple_module_hosting.manifest — do not edit by hand.
29
+ // Regenerate with: sm gen-pages
30
+ //
31
+ // Maps ModuleName -> record of page import paths. Paths below are
32
+ // relative to this file (host/client_app/) so that pages shipped inside
33
+ // pip-installed module wheels are picked up by Vite's import.meta.glob.
34
+ """
35
+
36
+ _GENERATED_CSS_HEADER = """\
37
+ /* AUTO-GENERATED by simple_module_hosting.manifest — do not edit by hand.
38
+ * Regenerate with: sm gen-pages
39
+ *
40
+ * @source entries for module pages shipped inside pip-installed wheels.
41
+ * In-repo modules are covered by the static @source glob in
42
+ * host/client_app/styles.css.
43
+ */
44
+ """
45
+
46
+
47
+ def repo_root_from_client_app(client_app_dir: Path) -> Path:
48
+ """Repo root is two levels above ``host/client_app/``.
49
+
50
+ Both ``write_module_pages_manifest`` and the ``sync-js-deps`` CLI
51
+ derive the workspace root from the host's client_app directory.
52
+ Centralized here so the heuristic lives in exactly one place.
53
+ """
54
+ return client_app_dir.resolve().parent.parent
55
+
56
+
57
+ def compute_module_pages(modules: Sequence[ModuleBase]) -> dict[str, Path]:
58
+ """Return a ``{ModuleName: absolute pages/ Path}`` map for modules that ship TSX pages.
59
+
60
+ A module is included only if ``<package>/pages/`` exists on disk.
61
+ """
62
+ result: dict[str, Path] = {}
63
+ for mod in modules:
64
+ pkg_name = get_module_package_name(mod)
65
+ try:
66
+ pkg_root = importlib.resources.files(pkg_name)
67
+ except ModuleNotFoundError:
68
+ logger.debug(
69
+ "Module '%s': package %s not importable — skipping", mod.meta.name, pkg_name
70
+ )
71
+ continue
72
+ pages_dir = Path(str(pkg_root)) / "pages"
73
+ if not pages_dir.is_dir():
74
+ logger.debug(
75
+ "Module '%s' has no pages/ at %s — skipping frontend manifest entry",
76
+ mod.meta.name,
77
+ pages_dir,
78
+ )
79
+ continue
80
+ result[mod.meta.name] = pages_dir.resolve()
81
+ return result
82
+
83
+
84
+ def _write_if_changed(path: Path, payload: str) -> bool:
85
+ """Write ``payload`` to ``path`` only if the on-disk content differs.
86
+
87
+ Avoids bumping the file's mtime on every dev-server boot, which would
88
+ otherwise cascade into Vite's file-watcher and trigger spurious HMR.
89
+ Returns True if a write happened.
90
+ """
91
+ try:
92
+ existing = path.read_text(encoding="utf-8")
93
+ except FileNotFoundError:
94
+ existing = None
95
+ if existing == payload:
96
+ return False
97
+ path.write_text(payload, encoding="utf-8")
98
+ return True
99
+
100
+
101
+ def _is_in_repo_module(pages_dir: Path, repo_root: Path | None) -> bool:
102
+ """True if ``pages_dir`` lives under the working tree's ``modules/`` dir.
103
+
104
+ Those modules are already covered by the static ``@source`` glob in
105
+ host/client_app/styles.css, so we shouldn't emit a duplicate absolute
106
+ entry for them in the generated CSS.
107
+ """
108
+ if repo_root is None:
109
+ return False
110
+ try:
111
+ rel = pages_dir.resolve().relative_to(repo_root.resolve())
112
+ except ValueError:
113
+ return False
114
+ return len(rel.parts) >= 2 and rel.parts[0] == "modules"
115
+
116
+
117
+ def write_module_pages_manifest(
118
+ modules: Sequence[ModuleBase],
119
+ output_dir: Path,
120
+ repo_root: Path | None = None,
121
+ ) -> dict[str, Path]:
122
+ """Write the three generated files (manifest JSON, glob TS, Tailwind CSS).
123
+
124
+ Returns the paths that were written. Content is written only when
125
+ different from what's on disk, so booting the host repeatedly in dev
126
+ does not wake up Vite's file watcher.
127
+ """
128
+ output_dir = Path(output_dir)
129
+ output_dir.mkdir(parents=True, exist_ok=True)
130
+ if repo_root is None:
131
+ repo_root = repo_root_from_client_app(output_dir)
132
+
133
+ pages_map = compute_module_pages(modules)
134
+
135
+ manifest_path = output_dir / "modules.manifest.json"
136
+ manifest_payload = {name: path.as_posix() for name, path in pages_map.items()}
137
+ manifest_text = json.dumps(manifest_payload, indent=2, sort_keys=True) + "\n"
138
+
139
+ generated_path = output_dir / "modules.generated.ts"
140
+ lines: list[str] = [
141
+ _GENERATED_TS_HEADER,
142
+ "",
143
+ "import type { ComponentType } from 'react';",
144
+ "",
145
+ "type PageModule = { default: ComponentType<Record<string, unknown>> };",
146
+ "",
147
+ "export const moduleGlobs: Record<string, Record<string, () => Promise<PageModule>>> = {",
148
+ ]
149
+ for name in sorted(pages_map):
150
+ lines.append(
151
+ f" {json.dumps(name)}: import.meta.glob<PageModule>"
152
+ f"({json.dumps(_glob_pattern_for(pages_map[name], output_dir))}),"
153
+ )
154
+ lines.append("};")
155
+ lines.append("")
156
+ generated_text = "\n".join(lines)
157
+
158
+ # In-repo modules are already covered by the static glob in styles.css;
159
+ # only emit @source for wheel-installed ones. pages_map is keyed by
160
+ # module name so each pages_dir is unique — no further dedup needed.
161
+ css_path = output_dir / "modules.generated.css"
162
+ css_lines: list[str] = [_GENERATED_CSS_HEADER]
163
+ for name in sorted(pages_map):
164
+ pages_dir = pages_map[name]
165
+ if _is_in_repo_module(pages_dir, repo_root):
166
+ continue
167
+ css_lines.append(f'@source "{pages_dir.as_posix()}";')
168
+ css_lines.append("")
169
+ css_text = "\n".join(css_lines)
170
+
171
+ wrote_manifest = _write_if_changed(manifest_path, manifest_text)
172
+ wrote_generated = _write_if_changed(generated_path, generated_text)
173
+ wrote_css = _write_if_changed(css_path, css_text)
174
+
175
+ if wrote_manifest or wrote_generated or wrote_css:
176
+ logger.info(
177
+ "Wrote module pages manifest: %d module(s) -> %s, %s, %s",
178
+ len(pages_map),
179
+ manifest_path.name,
180
+ generated_path.name,
181
+ css_path.name,
182
+ )
183
+ return {"manifest": manifest_path, "generated": generated_path, "css": css_path}
184
+
185
+
186
+ def _glob_pattern_for(pages_dir: Path, output_dir: Path) -> str:
187
+ """Build a Vite ``import.meta.glob`` pattern relative to ``output_dir``.
188
+
189
+ Vite 8 interprets filesystem-absolute paths against the project root,
190
+ not the filesystem, so we always emit the path relative to the file
191
+ where the glob lives (``modules.generated.ts`` under ``output_dir``).
192
+ """
193
+ try:
194
+ rel = Path(os.path.relpath(pages_dir, output_dir.resolve()))
195
+ except ValueError:
196
+ # Different drive on Windows — fall back to absolute (rare).
197
+ return pages_dir.as_posix() + "/**/*.tsx"
198
+ rel_str = rel.as_posix()
199
+ if not rel_str.startswith(("./", "../")):
200
+ rel_str = "./" + rel_str
201
+ return rel_str + "/**/*.tsx"
202
+
203
+
204
+ def read_module_package_json(mod: ModuleBase) -> dict | None:
205
+ """Return the parsed ``package.json`` for a module, or ``None`` if absent.
206
+
207
+ Tries two locations so both install modes work:
208
+
209
+ * ``<pkg>/package.json`` — force-included into the wheel by Hatch.
210
+ * ``<pkg>/../package.json`` — the source-tree module root, used by
211
+ editable installs and in-repo workspace members where the wheel
212
+ hasn't been built yet.
213
+ """
214
+ pkg_name = get_module_package_name(mod)
215
+ try:
216
+ pkg_root = Path(str(importlib.resources.files(pkg_name)))
217
+ except ModuleNotFoundError:
218
+ return None
219
+ for candidate in (pkg_root / "package.json", pkg_root.parent / "package.json"):
220
+ if candidate.is_file():
221
+ try:
222
+ return json.loads(candidate.read_text(encoding="utf-8"))
223
+ except json.JSONDecodeError as exc:
224
+ logger.warning(
225
+ "Module '%s' has an invalid package.json at %s: %s",
226
+ mod.meta.name,
227
+ candidate,
228
+ exc,
229
+ )
230
+ return None
231
+ return None
232
+
233
+
234
+ def collect_module_js_deps(modules: Sequence[ModuleBase]) -> dict[str, dict[str, str]]:
235
+ """Return ``{module_name: {dep: range}}`` for modules that declare deps.
236
+
237
+ Reads only the ``dependencies`` block; ``peerDependencies`` are
238
+ host-provided singletons and must not be installed from modules.
239
+ Modules without a ``package.json`` or without a ``dependencies`` block
240
+ are silently skipped.
241
+ """
242
+ out: dict[str, dict[str, str]] = {}
243
+ for mod in modules:
244
+ pkg = read_module_package_json(mod)
245
+ if not pkg:
246
+ continue
247
+ deps = pkg.get("dependencies") or {}
248
+ if deps:
249
+ out[mod.meta.name] = {str(k): str(v) for k, v in deps.items()}
250
+ return out
@@ -0,0 +1,272 @@
1
+ """Middleware: security headers, tenant isolation, Inertia shared-props.
2
+
3
+ Correlation IDs and request logging live in :mod:`._observability`.
4
+
5
+ All middleware classes use the raw ASGI pattern instead of ``BaseHTTPMiddleware``
6
+ to avoid its known issues with streaming responses, extra task creation,
7
+ and ``ContextVar`` propagation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from collections.abc import Callable
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from simple_module_db import current_tenant_id
17
+ from starlette.datastructures import Headers, MutableHeaders
18
+ from starlette.requests import Request
19
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
20
+
21
+ from simple_module_hosting._inertia_shared import build_i18n_block
22
+ from simple_module_hosting._observability import (
23
+ CorrelationIdMiddleware,
24
+ RequestLoggingMiddleware,
25
+ )
26
+ from simple_module_hosting.permissions import expand_permissions, resolve_permissions
27
+
28
+ if TYPE_CHECKING:
29
+ from simple_module_core.menu import MenuRegistry
30
+ from simple_module_core.permissions import PermissionRegistry
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _SCOPE_HTTP = "http"
35
+ _MSG_RESPONSE_START = "http.response.start"
36
+
37
+ # Security response header names
38
+ _HEADER_X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options"
39
+ _HEADER_X_FRAME_OPTIONS = "X-Frame-Options"
40
+ _HEADER_X_XSS_PROTECTION = "X-XSS-Protection"
41
+ _HEADER_REFERRER_POLICY = "Referrer-Policy"
42
+ _HEADER_CSP = "Content-Security-Policy"
43
+ _HEADER_HSTS = "Strict-Transport-Security"
44
+
45
+ # Security response header values
46
+ _XCTO_NOSNIFF = "nosniff"
47
+ _XFO_SAMEORIGIN = "SAMEORIGIN"
48
+ _XXSS_BLOCK = "1; mode=block"
49
+ _REFERRER_STRICT_ORIGIN = "strict-origin-when-cross-origin"
50
+
51
+ __all__ = [
52
+ "TENANT_HEADER",
53
+ "CorrelationIdMiddleware",
54
+ "InertiaLayoutDataMiddleware",
55
+ "RequestLoggingMiddleware",
56
+ "SecurityHeadersMiddleware",
57
+ "TenantMiddleware",
58
+ ]
59
+
60
+
61
+ class SecurityHeadersMiddleware:
62
+ """Add security headers to every response.
63
+
64
+ ``content_security_policy`` and ``strict_transport_security`` accept a
65
+ string to override the defaults, or ``None`` to suppress that header
66
+ (useful in development when Vite's HMR client loads cross-origin scripts,
67
+ or behind plain-HTTP loopbacks where HSTS would lock users out).
68
+ """
69
+
70
+ _DEFAULT_CSP = (
71
+ "default-src 'self'; "
72
+ # Inertia embeds the initial page blob inline; Vite injects a React
73
+ # Refresh shim at boot. Both require 'unsafe-inline' for scripts.
74
+ # Production builds compile to hashed bundles, so this can be
75
+ # tightened with a nonce once Vite's preamble is removed in prod.
76
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
77
+ "script-src-elem 'self' 'unsafe-inline'; "
78
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
79
+ "img-src 'self' data: blob:; "
80
+ "font-src 'self' https://fonts.gstatic.com data:; "
81
+ "connect-src 'self'; "
82
+ "frame-ancestors 'self'; "
83
+ "base-uri 'self'; "
84
+ "form-action 'self'"
85
+ )
86
+ _DEFAULT_HSTS = "max-age=31536000; includeSubDomains"
87
+
88
+ @staticmethod
89
+ def dev_csp(vite_dev_url: str) -> str:
90
+ """Build a dev CSP that whitelists the Vite dev server.
91
+
92
+ In development the browser fetches ``@vite/client``, ``main.tsx``, and
93
+ React Refresh from the Vite origin (default ``http://localhost:5050``),
94
+ and opens a WebSocket there for HMR. Those fail under the prod CSP,
95
+ so we widen ``script-src*``/``connect-src``/``style-src`` for that
96
+ origin only (including the ``ws://`` equivalent for HMR).
97
+ """
98
+ ws_url = vite_dev_url.replace("http://", "ws://").replace("https://", "wss://")
99
+ return (
100
+ "default-src 'self'; "
101
+ f"script-src 'self' 'unsafe-inline' 'unsafe-eval' {vite_dev_url}; "
102
+ f"script-src-elem 'self' 'unsafe-inline' {vite_dev_url}; "
103
+ f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com {vite_dev_url}; "
104
+ "img-src 'self' data: blob:; "
105
+ "font-src 'self' https://fonts.gstatic.com data:; "
106
+ f"connect-src 'self' {vite_dev_url} {ws_url}; "
107
+ "frame-ancestors 'self'; "
108
+ "base-uri 'self'; "
109
+ "form-action 'self'"
110
+ )
111
+
112
+ def __init__(
113
+ self,
114
+ app: ASGIApp,
115
+ *,
116
+ content_security_policy: str | None = _DEFAULT_CSP,
117
+ strict_transport_security: str | None = _DEFAULT_HSTS,
118
+ ) -> None:
119
+ self.app = app
120
+ self.csp = content_security_policy
121
+ self.hsts = strict_transport_security
122
+
123
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
124
+ if scope["type"] != _SCOPE_HTTP:
125
+ await self.app(scope, receive, send)
126
+ return
127
+
128
+ async def send_with_headers(message: Message) -> None:
129
+ if message["type"] == _MSG_RESPONSE_START:
130
+ headers = MutableHeaders(scope=message)
131
+ headers[_HEADER_X_CONTENT_TYPE_OPTIONS] = _XCTO_NOSNIFF
132
+ headers[_HEADER_X_FRAME_OPTIONS] = _XFO_SAMEORIGIN
133
+ headers[_HEADER_X_XSS_PROTECTION] = _XXSS_BLOCK
134
+ headers[_HEADER_REFERRER_POLICY] = _REFERRER_STRICT_ORIGIN
135
+ if self.csp:
136
+ headers[_HEADER_CSP] = self.csp
137
+ if self.hsts:
138
+ headers[_HEADER_HSTS] = self.hsts
139
+ await send(message)
140
+
141
+ await self.app(scope, receive, send_with_headers)
142
+
143
+
144
+ TENANT_HEADER = "X-Tenant-ID"
145
+
146
+
147
+ class TenantMiddleware:
148
+ """Extract tenant context from authenticated user or request header.
149
+
150
+ Sets the ``current_tenant_id`` context var so that DB queries on
151
+ :class:`~simple_module_db.mixins.MultiTenantMixin` models are
152
+ automatically filtered, and new objects get ``tenant_id`` populated.
153
+
154
+ Also stores the resolved value on ``request.state.tenant_id``.
155
+
156
+ Tenant is resolved from (in priority order):
157
+
158
+ 1. Authenticated user's ``tenant_id`` attribute (from auth token claims).
159
+ 2. The configured request header, if any — useful for API clients
160
+ and tests. Pass ``header=None`` (the default) to disable the
161
+ header source and force tenant resolution through the auth token
162
+ only. Pass the header name (e.g. ``"X-Tenant-ID"``) to enable.
163
+ """
164
+
165
+ def __init__(self, app: ASGIApp, *, header: str | None = None) -> None:
166
+ self.app = app
167
+ self.header = header
168
+
169
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
170
+ if scope["type"] != _SCOPE_HTTP:
171
+ await self.app(scope, receive, send)
172
+ return
173
+
174
+ request = Request(scope)
175
+ tenant_id: str | None = None
176
+
177
+ user = getattr(request.state, "user", None)
178
+ if user is not None:
179
+ tenant_id = getattr(user, "tenant_id", None)
180
+
181
+ if tenant_id is None and self.header:
182
+ header_value = Headers(scope=scope).get(self.header)
183
+ if header_value:
184
+ tenant_id = header_value
185
+
186
+ request.state.tenant_id = tenant_id
187
+
188
+ if tenant_id is not None:
189
+ token = current_tenant_id.set(tenant_id)
190
+ try:
191
+ await self.app(scope, receive, send)
192
+ finally:
193
+ current_tenant_id.reset(token)
194
+ return
195
+
196
+ await self.app(scope, receive, send)
197
+
198
+
199
+ PrincipalSerializer = Callable[[Any], dict[str, Any]]
200
+ """Module-owned projection from ``request.state.user`` to the ``auth.user``
201
+ shared-prop dict. Framework never inspects user fields beyond ``roles``; a
202
+ module (typically ``users``) registers a serializer on ``app.state`` so the
203
+ user-schema stays out of the hosting layer."""
204
+
205
+
206
+ class InertiaLayoutDataMiddleware:
207
+ """Inject shared data (auth, menus, i18n) into every Inertia response.
208
+
209
+ This middleware reads the user from ``request.state.user`` (set by auth middleware)
210
+ and populates ``request.state.inertia_shared`` for the Inertia render function.
211
+
212
+ The ``auth.user`` projection is produced by a module-registered serializer
213
+ looked up on ``request.app.state.principal_serializer``. Without one,
214
+ ``auth.user`` is ``None`` even when a user is authenticated.
215
+ """
216
+
217
+ def __init__(
218
+ self,
219
+ app: ASGIApp,
220
+ menu_registry: MenuRegistry,
221
+ permission_registry: PermissionRegistry,
222
+ ) -> None:
223
+ self.app = app
224
+ self.menu_registry = menu_registry
225
+ self.permission_registry = permission_registry
226
+
227
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
228
+ if scope["type"] != _SCOPE_HTTP:
229
+ await self.app(scope, receive, send)
230
+ return
231
+
232
+ request = Request(scope)
233
+ user = getattr(request.state, "user", None)
234
+ is_authenticated = user is not None
235
+ roles = getattr(user, "roles", []) if user else []
236
+
237
+ # Resolve permissions once and cache on request.state for RequiresPermission
238
+ resolved = (
239
+ resolve_permissions(roles, role_map=self.permission_registry.role_map)
240
+ if is_authenticated
241
+ else set()
242
+ )
243
+ request.state.resolved_permissions = resolved
244
+
245
+ # Expand wildcard to full list for frontend (no "*" leak)
246
+ all_perms = self.permission_registry.all_permissions
247
+ frontend_permissions = expand_permissions(resolved, all_perms) if is_authenticated else []
248
+
249
+ i18n_block = build_i18n_block(scope, request)
250
+
251
+ principal_serializer: PrincipalSerializer | None = getattr(
252
+ scope["app"].state, "principal_serializer", None
253
+ )
254
+ user_payload: dict[str, Any] | None = (
255
+ principal_serializer(user) if user is not None and principal_serializer else None
256
+ )
257
+
258
+ shared: dict = {
259
+ "auth": {
260
+ "user": user_payload,
261
+ "isAuthenticated": is_authenticated,
262
+ "permissions": frontend_permissions,
263
+ },
264
+ "menus": self.menu_registry.get_for_user(
265
+ is_authenticated=is_authenticated,
266
+ roles=roles,
267
+ ),
268
+ "i18n": i18n_block,
269
+ }
270
+ request.state.inertia_shared = shared
271
+
272
+ await self.app(scope, receive, send)
@@ -0,0 +1,65 @@
1
+ """Alembic migration check performed during app startup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def resolve_head_revision(alembic_ini_path: str = "host/alembic.ini") -> str | None:
11
+ """Return the current head revision string, or ``None`` if alembic
12
+ isn't configured at ``alembic_ini_path`` or has no revisions."""
13
+ from alembic.config import Config as AlembicConfig
14
+ from alembic.script import ScriptDirectory
15
+ from alembic.util.exc import CommandError
16
+
17
+ try:
18
+ return ScriptDirectory.from_config(AlembicConfig(alembic_ini_path)).get_current_head()
19
+ except (CommandError, FileNotFoundError) as exc:
20
+ logger.debug("Alembic not available: %s", exc)
21
+ return None
22
+
23
+
24
+ async def check_migrations(engine, alembic_ini_path: str = "host/alembic.ini") -> dict:
25
+ """Check database migration state. Raises RuntimeError if not at head.
26
+
27
+ Returns a dict with migration status for storage on app.state.
28
+ """
29
+ from alembic.config import Config as AlembicConfig
30
+ from alembic.runtime.migration import MigrationContext
31
+ from alembic.script import ScriptDirectory
32
+
33
+ _no_migrations = {
34
+ "current_revision": None,
35
+ "head_revision": None,
36
+ "is_current": True,
37
+ "pending_count": 0,
38
+ }
39
+
40
+ head = resolve_head_revision(alembic_ini_path)
41
+ if head is None:
42
+ return _no_migrations
43
+ script = ScriptDirectory.from_config(AlembicConfig(alembic_ini_path))
44
+
45
+ async with engine.connect() as conn:
46
+
47
+ def _get_current(sync_conn):
48
+ ctx = MigrationContext.configure(sync_conn)
49
+ return ctx.get_current_revision()
50
+
51
+ current = await conn.run_sync(_get_current)
52
+
53
+ if current != head:
54
+ pending = list(script.iterate_revisions(head, current))
55
+ raise RuntimeError(
56
+ f"Database is {len(pending)} revision(s) behind "
57
+ f"(at {current!r}, head is {head!r}). Run: make migrate"
58
+ )
59
+
60
+ return {
61
+ "current_revision": current,
62
+ "head_revision": head,
63
+ "is_current": True,
64
+ "pending_count": 0,
65
+ }
@@ -0,0 +1,75 @@
1
+ """Permission enforcement dependency for FastAPI endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import HTTPException, Request
6
+ from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD
7
+
8
+ __all__ = [
9
+ "DEFAULT_ROLE_PERMISSIONS",
10
+ "WILDCARD",
11
+ "RequiresPermission",
12
+ "expand_permissions",
13
+ "resolve_permissions",
14
+ ]
15
+
16
+
17
+ def resolve_permissions(
18
+ roles: list[str],
19
+ role_map: dict[str, list[str]] | None = None,
20
+ ) -> set[str]:
21
+ """Resolve a set of roles into a flat set of permission strings."""
22
+ if role_map is None:
23
+ role_map = DEFAULT_ROLE_PERMISSIONS
24
+ permissions: set[str] = set()
25
+ for role in roles:
26
+ permissions.update(role_map.get(role, []))
27
+ return permissions
28
+
29
+
30
+ def expand_permissions(
31
+ resolved: set[str],
32
+ all_permissions: list[str],
33
+ ) -> list[str]:
34
+ """Expand wildcard to the full permission list for frontend consumption."""
35
+ if WILDCARD in resolved:
36
+ return sorted(set(all_permissions))
37
+ return sorted(resolved)
38
+
39
+
40
+ class RequiresPermission:
41
+ """FastAPI dependency that enforces a specific permission.
42
+
43
+ Usage::
44
+
45
+ @router.post("/", dependencies=[Depends(RequiresPermission("products.create"))])
46
+ async def create_product(...):
47
+ ...
48
+ """
49
+
50
+ def __init__(self, permission: str) -> None:
51
+ self.permission = permission
52
+
53
+ def __call__(self, request: Request) -> None:
54
+ user = getattr(request.state, "user", None)
55
+ if user is None:
56
+ raise HTTPException(status_code=401, detail="Authentication required")
57
+
58
+ # Use cached permissions from middleware if available
59
+ permissions: set[str] | None = getattr(request.state, "resolved_permissions", None)
60
+ if permissions is None:
61
+ # Fallback: middleware did not run — consult registry role_map if available
62
+ sm = getattr(getattr(request.app, "state", None), "sm", None)
63
+ perm_registry = getattr(sm, "permissions", None) if sm is not None else None
64
+ role_map = perm_registry.role_map if perm_registry is not None else None
65
+ permissions = resolve_permissions(user.roles, role_map=role_map)
66
+ request.state.resolved_permissions = permissions
67
+
68
+ if WILDCARD in permissions:
69
+ return
70
+
71
+ if self.permission not in permissions:
72
+ raise HTTPException(
73
+ status_code=403,
74
+ detail=f"Permission required: {self.permission}",
75
+ )
File without changes