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.
- simple_module_hosting/__init__.py +7 -0
- simple_module_hosting/_error_handlers.py +54 -0
- simple_module_hosting/_hydrate_step.py +39 -0
- simple_module_hosting/_inertia_setup.py +73 -0
- simple_module_hosting/_inertia_shared.py +61 -0
- simple_module_hosting/_observability.py +108 -0
- simple_module_hosting/_phase_helpers.py +160 -0
- simple_module_hosting/app_builder.py +281 -0
- simple_module_hosting/bootstrap_settings.py +55 -0
- simple_module_hosting/cli.py +292 -0
- simple_module_hosting/health.py +79 -0
- simple_module_hosting/host_settings.py +33 -0
- simple_module_hosting/i18n_deps.py +25 -0
- simple_module_hosting/i18n_manifest.py +202 -0
- simple_module_hosting/i18n_middleware.py +95 -0
- simple_module_hosting/inertia_deps.py +27 -0
- simple_module_hosting/inertia_utils.py +31 -0
- simple_module_hosting/logging.py +91 -0
- simple_module_hosting/manifest.py +250 -0
- simple_module_hosting/middleware.py +272 -0
- simple_module_hosting/migrations.py +65 -0
- simple_module_hosting/permissions.py +75 -0
- simple_module_hosting/py.typed +0 -0
- simple_module_hosting/redirects.py +45 -0
- simple_module_hosting/scaffolding.py +294 -0
- simple_module_hosting/settings.py +10 -0
- simple_module_hosting/templates/host/.env.example +20 -0
- simple_module_hosting/templates/host/.gitignore +19 -0
- simple_module_hosting/templates/host/Makefile +24 -0
- simple_module_hosting/templates/host/README.md.tpl +59 -0
- simple_module_hosting/templates/host/alembic.ini +36 -0
- simple_module_hosting/templates/host/client_app/app.tsx +16 -0
- simple_module_hosting/templates/host/client_app/main.tsx +2 -0
- simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
- simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_hosting/templates/host/client_app/pages.ts +47 -0
- simple_module_hosting/templates/host/client_app/styles.css +7 -0
- simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
- simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
- simple_module_hosting/templates/host/main.py +27 -0
- simple_module_hosting/templates/host/migrations/env.py +80 -0
- simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
- simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
- simple_module_hosting/templates/host/templates/index.html +12 -0
- simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_hosting/templates/module/.gitignore +14 -0
- simple_module_hosting/templates/module/README.md.tpl +82 -0
- simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_hosting/templates/module/package.json.tpl +16 -0
- simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
- simple_module_hosting/templates/module/tests/__init__.py +0 -0
- simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
- simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
- simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
- simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
- simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
- 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
|