Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,6 @@ from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||
from mizan_core.upload import File, Upload, UploadedFile
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
@@ -165,10 +164,6 @@ __all__ = [
|
||||
"GlobalContext",
|
||||
"ServerFunction",
|
||||
"ComposedContext",
|
||||
# File uploads
|
||||
"Upload",
|
||||
"File",
|
||||
"UploadedFile",
|
||||
# Setup
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Cache Module — Known Issues
|
||||
|
||||
Open issues against the current cache implementation. The cache uses
|
||||
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key;
|
||||
broad purge is a prefix SCAN+UNLINK), so there are no index/sub-index races to
|
||||
track. Resolved items are removed once their fix lands.
|
||||
Open issues against the current cache implementation. Resolved items are
|
||||
removed once their fix lands.
|
||||
|
||||
## Correctness
|
||||
|
||||
### Purge race condition (non-atomic index operations)
|
||||
`cache_purge` reads the index and deletes as separate operations. A
|
||||
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||
|
||||
### Cross-language stringification divergence
|
||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||
@@ -15,6 +19,22 @@ TypeScript HMAC keys can still diverge on an un-normalized type.
|
||||
|
||||
## Performance / Operability
|
||||
|
||||
### Broad purge leaves per-param sub-indexes
|
||||
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||
sub-indexes — a slow Redis memory leak.
|
||||
|
||||
### No thundering-herd protection
|
||||
Concurrent cold misses on the same key all execute and write. No
|
||||
single-flight / request-coalescing.
|
||||
|
||||
## API shape
|
||||
|
||||
### cache_get / cache_put argument inconsistency
|
||||
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||
inputs from module globals — two access patterns for one concern.
|
||||
|
||||
## Coverage
|
||||
|
||||
### RedisCache lacks test coverage
|
||||
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||
|
||||
@@ -29,10 +29,6 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||
from mizan_core.registry import get_function, get_context_groups
|
||||
from mizan_core.upload import UploadedFile, bind_uploads
|
||||
from mizan_core import invalidation as _core_inval
|
||||
from mizan_core.authguard import enforce_auth as _core_enforce_auth
|
||||
from mizan_core.errors import MizanError as _CoreMizanError
|
||||
from mizan.setup.settings import get_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -116,14 +112,53 @@ def _check_auth_requirement(
|
||||
Django User (from session). Either way, no additional DB query is made
|
||||
for the built-in checks. Custom callables may query DB if they choose.
|
||||
"""
|
||||
# Evaluation lives in the shared core (mizan_core.authguard); the callable
|
||||
# path receives the native Django request. Core raises; we render to the
|
||||
# Django-shim FunctionError shape the executor expects.
|
||||
try:
|
||||
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
|
||||
if auth_requirement is None:
|
||||
return None
|
||||
except _CoreMizanError as e:
|
||||
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
|
||||
|
||||
user = request.user
|
||||
|
||||
# Handle callable auth
|
||||
if callable(auth_requirement):
|
||||
try:
|
||||
result = auth_requirement(request)
|
||||
if result:
|
||||
return None # Authorized
|
||||
else:
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Access denied",
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Custom error message from the callable
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message=str(e) or "Access denied",
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Superuser access required",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
_cache_log = logging.getLogger("mizan.cache")
|
||||
@@ -162,6 +197,51 @@ def _purge_cache_for_invalidation(
|
||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""
|
||||
Determine whether an affects target is a context name or function name.
|
||||
|
||||
Returns:
|
||||
("context", "user", None) — full context invalidation
|
||||
("function", "user_profile", "user") — function within context
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
|
||||
# Check if it's a context name directly
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
|
||||
# Check if it's a function name within a context
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
|
||||
# Not a context or context function — treat as context name anyway
|
||||
# (it might be a non-context function or an as-yet-unregistered context)
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _get_context_param_names(context_name: str) -> set[str]:
|
||||
"""
|
||||
Get the set of parameter names used by functions in a context.
|
||||
|
||||
Returns the union of all Input field names across context functions.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
param_names: set[str] = set()
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||
param_names.update(input_cls.model_fields.keys())
|
||||
|
||||
return param_names
|
||||
|
||||
|
||||
def _resolve_invalidation(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None = None,
|
||||
@@ -180,7 +260,49 @@ def _resolve_invalidation(
|
||||
Returns a list suitable for both JSON body and header serialization.
|
||||
Returns None if no invalidation needed.
|
||||
"""
|
||||
return _core_inval.resolve_invalidation(view_class, input_data)
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
affects = meta.get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
result = []
|
||||
seen = set()
|
||||
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
# Function-level: use the function name as the invalidation key
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_name in seen:
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
# Resolve the context this target belongs to (for param lookup)
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
# Tier 1: argument name matching
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _get_context_param_names(ctx_for_params)
|
||||
matched = {
|
||||
k: v for k, v in input_data.items()
|
||||
if k in context_params
|
||||
}
|
||||
if matched:
|
||||
result.append({"context": target_name, "params": matched})
|
||||
continue
|
||||
|
||||
# Tier 3: broad fallback
|
||||
result.append(target_name)
|
||||
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _resolve_merges(
|
||||
@@ -199,12 +321,94 @@ def _resolve_merges(
|
||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||
Entries whose slot can't be uniquely resolved are dropped.
|
||||
"""
|
||||
return _core_inval.resolve_merges(view_class, input_data, result_data)
|
||||
if view_class is None:
|
||||
return None
|
||||
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
targets = meta.get("merge") or []
|
||||
if not targets:
|
||||
return None
|
||||
|
||||
mutation_output = getattr(view_class, "Output", None)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for ctx_name in targets:
|
||||
if ctx_name in seen:
|
||||
continue
|
||||
seen.add(ctx_name)
|
||||
|
||||
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
|
||||
if slot is None:
|
||||
continue
|
||||
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||
if input_data:
|
||||
context_params = _get_context_param_names(ctx_name)
|
||||
matched = {
|
||||
k: v for k, v in input_data.items()
|
||||
if k in context_params
|
||||
}
|
||||
if matched:
|
||||
entry["params"] = matched
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
||||
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core)."""
|
||||
return _core_inval.format_invalidate_header(invalidate)
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name, [])
|
||||
matches: list[str] = []
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
fn_output = getattr(fn_cls, "Output", None)
|
||||
if fn_output is not None and type_matcher(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _format_invalidate_header(
|
||||
invalidate: list[str | dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||
|
||||
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||
Param values are URL-encoded to prevent delimiter collisions.
|
||||
|
||||
Examples:
|
||||
["user"] → "user"
|
||||
["user", "notifications"] → "user, notifications"
|
||||
[{"context": "user", "params": {"user_id": 5}}]
|
||||
→ "user;user_id=5"
|
||||
[{"context": "search", "params": {"q": "hello world"}}]
|
||||
→ "search;q=hello%20world"
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
parts = []
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params", {})
|
||||
if params:
|
||||
param_str = ";".join(
|
||||
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||
for k, v in sorted(params.items())
|
||||
)
|
||||
parts.append(f"{ctx};{param_str}")
|
||||
else:
|
||||
parts.append(ctx)
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def execute_function(
|
||||
@@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
is_multipart = content_type.startswith("multipart/form-data")
|
||||
|
||||
if is_multipart:
|
||||
# Multipart carries two shapes: a form submission (Django Form path) or
|
||||
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
|
||||
# Multipart form data - used by form submit functions
|
||||
fn_name = request.POST.get("fn")
|
||||
if not fn_name:
|
||||
return FunctionError(
|
||||
@@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
message="Missing 'fn' field",
|
||||
).to_response()
|
||||
|
||||
fn_class = get_function(fn_name)
|
||||
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
|
||||
# Get form data (excluding 'fn')
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
if is_form_fn:
|
||||
# Form submit — POST fields + FILES handed to Django Form validation.
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
else:
|
||||
# Upload RPC — the `args` JSON part carries the non-file fields; the
|
||||
# file parts bind into the Input's Upload fields (constraints enforced).
|
||||
raw_args = request.POST.get("args")
|
||||
try:
|
||||
input_data = json.loads(raw_args) if raw_args else {}
|
||||
except json.JSONDecodeError:
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Invalid JSON in 'args' field",
|
||||
).to_response()
|
||||
input_cls = getattr(fn_class, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
files = {
|
||||
field: [
|
||||
UploadedFile(f.name, f.content_type, f.read())
|
||||
for f in request.FILES.getlist(field)
|
||||
]
|
||||
for field in request.FILES
|
||||
}
|
||||
err = bind_uploads(input_cls, input_data, files)
|
||||
if err is not None:
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message=err,
|
||||
).to_response()
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator (Django adapter surface).
|
||||
Mizan Edge Manifest Generator.
|
||||
|
||||
The manifest derivation is AFI-common and lives in `mizan_core.manifest`;
|
||||
Django exposes it through `python manage.py export_edge_manifest` and this
|
||||
re-export. The manifest maps contexts to URL patterns and params, consumed by
|
||||
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
|
||||
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
@@ -13,10 +12,145 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
"""
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
|
||||
groups = get_context_groups()
|
||||
registry = get_registry()
|
||||
all_functions = registry.get("functions", {})
|
||||
|
||||
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||
|
||||
for ctx_name, fn_names in sorted(groups.items()):
|
||||
param_names: set[str] = set()
|
||||
functions_meta: list[dict[str, Any]] = []
|
||||
page_routes: list[str] = []
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = all_functions.get(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
for param_name in input_cls.model_fields:
|
||||
param_names.add(param_name)
|
||||
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
route = meta.get("route")
|
||||
view_path = meta.get("view_path")
|
||||
|
||||
fn_entry: dict[str, Any] = {
|
||||
"name": fn_name,
|
||||
"path": "view" if view_path else "rpc",
|
||||
}
|
||||
if route:
|
||||
fn_entry["route"] = route
|
||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||
page_routes.append(route)
|
||||
if meta.get("rev"):
|
||||
fn_entry["rev"] = meta["rev"]
|
||||
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||
fn_entry["cache"] = meta["cache"]
|
||||
functions_meta.append(fn_entry)
|
||||
|
||||
sorted_params = sorted(param_names)
|
||||
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||
|
||||
ctx_entry: dict[str, Any] = {
|
||||
"functions": functions_meta,
|
||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||
"params": sorted_params,
|
||||
"user_scoped": user_scoped,
|
||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||
}
|
||||
|
||||
if page_routes:
|
||||
ctx_entry["page_routes"] = page_routes
|
||||
if view_urls and ctx_name in view_urls:
|
||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||
|
||||
manifest["contexts"][ctx_name] = ctx_entry
|
||||
|
||||
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
if not meta.get("affects"):
|
||||
continue
|
||||
|
||||
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||
|
||||
# Auto-scoped params — function params that match context params
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
fn_params = set(input_cls.model_fields.keys())
|
||||
auto_scoped: list[str] = []
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_param_names: set[str] = set()
|
||||
ctx_fns = groups.get(ctx_name, [])
|
||||
for ctx_fn_name in ctx_fns:
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls is None:
|
||||
continue
|
||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||
for p in fn_params:
|
||||
if p in ctx_param_names and p not in auto_scoped:
|
||||
auto_scoped.append(p)
|
||||
if auto_scoped:
|
||||
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||
|
||||
if meta.get("private"):
|
||||
mutation["private"] = True
|
||||
if meta.get("route"):
|
||||
mutation["route"] = meta["route"]
|
||||
mutation["methods"] = meta.get("methods", ["POST"])
|
||||
|
||||
manifest["mutations"][fn_name] = mutation
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def generate_edge_manifest_json(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
indent: int = 2,
|
||||
) -> str:
|
||||
"""JSON-serialize the Edge manifest."""
|
||||
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||
|
||||
@@ -1,79 +1,245 @@
|
||||
"""
|
||||
JWT tokens — the Django adapter over the shared core (`mizan_core.auth.jwt`).
|
||||
JWT Token Creation and Validation
|
||||
|
||||
The token logic (mint/decode/refresh, `JWTUser`, `TokenPair`, `TokenPayload`)
|
||||
lives in the core; this module binds it to Django settings and keeps the
|
||||
session-revocation check (`validate_session`), which is Django-session-specific.
|
||||
Uses PyJWT directly - no allauth dependency.
|
||||
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from mizan_core.auth import jwt as _core_jwt
|
||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload
|
||||
import jwt
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
|
||||
from .settings import get_settings
|
||||
|
||||
__all__ = [
|
||||
"TokenPair",
|
||||
"TokenPayload",
|
||||
"JWTUser",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"create_token_pair",
|
||||
"decode_token",
|
||||
"validate_session",
|
||||
"refresh_tokens",
|
||||
]
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
"""Access and refresh token pair."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _config() -> JWTConfig:
|
||||
s = get_settings()
|
||||
return JWTConfig(
|
||||
private_key=s.private_key,
|
||||
public_key=s.public_key,
|
||||
algorithm=s.algorithm,
|
||||
access_token_expires_in=s.access_token_expires_in,
|
||||
refresh_token_expires_in=s.refresh_token_expires_in,
|
||||
class TokenPayload(NamedTuple):
|
||||
"""Decoded token payload."""
|
||||
user_id: int | str
|
||||
session_key: str
|
||||
token_type: str
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
exp: int
|
||||
iat: int
|
||||
|
||||
|
||||
class JWTUser:
|
||||
"""
|
||||
Minimal user object created from JWT claims.
|
||||
|
||||
Used as request.user for JWT-authenticated requests.
|
||||
No database query required - all data comes from the token.
|
||||
|
||||
If you need the full User object with all fields, query explicitly:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
"""
|
||||
|
||||
def __init__(self, payload: TokenPayload):
|
||||
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
|
||||
self.pk = self.id
|
||||
self.is_staff = payload.is_staff
|
||||
self.is_superuser = payload.is_superuser
|
||||
self.is_authenticated = True
|
||||
self.is_anonymous = False
|
||||
self.is_active = True # Assumed active if they have a valid token
|
||||
|
||||
def __str__(self):
|
||||
return f"JWTUser(id={self.id})"
|
||||
|
||||
def __repr__(self):
|
||||
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "access"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + settings.access_token_expires_in,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.private_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
||||
return _core_jwt.create_access_token(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def create_refresh_token(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a longer-lived refresh token.
|
||||
|
||||
The token contains:
|
||||
- sub: user ID
|
||||
- sid: session key (for revocation checking)
|
||||
- staff: is_staff flag
|
||||
- super: is_superuser flag
|
||||
- type: "refresh"
|
||||
- iat: issued at
|
||||
- exp: expiration
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = int(time.time())
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": "refresh",
|
||||
"iat": now,
|
||||
"exp": now + settings.refresh_token_expires_in,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.private_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
||||
return _core_jwt.create_refresh_token(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def create_token_pair(
|
||||
user_id: int | str,
|
||||
session_key: str,
|
||||
*,
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> TokenPair:
|
||||
"""Create both access and refresh tokens."""
|
||||
settings = get_settings()
|
||||
return TokenPair(
|
||||
access_token=create_access_token(
|
||||
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||
),
|
||||
refresh_token=create_refresh_token(
|
||||
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||
),
|
||||
expires_in=settings.access_token_expires_in,
|
||||
)
|
||||
|
||||
|
||||
def create_token_pair(user_id, session_key, *, is_staff=False, is_superuser=False) -> TokenPair:
|
||||
return _core_jwt.create_token_pair(user_id, session_key, _config(),
|
||||
is_staff=is_staff, is_superuser=is_superuser)
|
||||
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Returns None if:
|
||||
- Token is invalid or expired
|
||||
- Token type doesn't match expected_type (if specified)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
||||
return _core_jwt.decode_token(token, _config(), expected_type=expected_type)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.public_key,
|
||||
algorithms=[settings.algorithm],
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
# Validate token type if specified
|
||||
if expected_type and payload.get("type") != expected_type:
|
||||
return None
|
||||
|
||||
return TokenPayload(
|
||||
user_id=payload["sub"],
|
||||
session_key=payload["sid"],
|
||||
token_type=payload["type"],
|
||||
is_staff=payload.get("staff", False),
|
||||
is_superuser=payload.get("super", False),
|
||||
exp=payload["exp"],
|
||||
iat=payload["iat"],
|
||||
)
|
||||
|
||||
|
||||
def validate_session(session_key: str) -> bool:
|
||||
"""Immediate-logout revocation: is this Django session still alive?
|
||||
"""
|
||||
Check if a session is still valid (exists and not expired).
|
||||
|
||||
Honors `JWT_VALIDATE_SESSION` — when disabled, always True. This is the one
|
||||
Django-session-bound piece; the core's `refresh_tokens` takes it as an
|
||||
injected `session_validator`.
|
||||
This is the key to immediate logout revocation - if the session
|
||||
is destroyed, tokens tied to it become invalid.
|
||||
"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
if not get_settings().validate_session:
|
||||
jwt_settings = get_settings()
|
||||
|
||||
if not jwt_settings.validate_session:
|
||||
return True
|
||||
|
||||
# Use the configured session engine
|
||||
engine = import_module(django_settings.SESSION_ENGINE)
|
||||
session = engine.SessionStore(session_key=session_key)
|
||||
SessionStore = engine.SessionStore
|
||||
|
||||
# Try to load the session
|
||||
session = SessionStore(session_key=session_key)
|
||||
|
||||
# Check if session exists and is not empty
|
||||
# exists() is more reliable than checking load() result
|
||||
return session.exists(session_key)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||
return _core_jwt.refresh_tokens(refresh_token, _config(), session_validator=validate_session)
|
||||
"""
|
||||
Use a refresh token to obtain new tokens.
|
||||
|
||||
Returns None if:
|
||||
- Refresh token is invalid or expired
|
||||
- Associated session no longer exists
|
||||
"""
|
||||
payload = decode_token(refresh_token, expected_type="refresh")
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
# Validate the session still exists
|
||||
if not validate_session(payload.session_key):
|
||||
return None
|
||||
|
||||
# Issue new token pair with same claims
|
||||
return create_token_pair(
|
||||
payload.user_id,
|
||||
payload.session_key,
|
||||
is_staff=payload.is_staff,
|
||||
is_superuser=payload.is_superuser,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
from .bridge import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
|
||||
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
181
backends/mizan-django/src/mizan/ssr/bridge.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
|
||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||
Response: {"id": 1, "html": "<div>...</div>"}
|
||||
|
||||
The subprocess stays alive across requests. It is started on first use
|
||||
and restarted automatically if it crashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("mizan.ssr")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""Result of an SSR render call."""
|
||||
html: str
|
||||
|
||||
|
||||
class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||
self._worker_path = worker_path
|
||||
self._timeout = timeout
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||
self._counter = 0
|
||||
self._pending: dict[int, threading.Event] = {}
|
||||
self._results: dict[int, dict] = {}
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure cleanup on process exit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
"""Start the Bun subprocess if it's not running."""
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
|
||||
if self._proc is not None:
|
||||
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
|
||||
|
||||
self._ready.clear()
|
||||
self._proc = subprocess.Popen(
|
||||
["bun", "run", self._worker_path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Wait for the "ready" signal from the worker
|
||||
if not self._ready.wait(timeout=self._timeout):
|
||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||
self.shutdown()
|
||||
raise TimeoutError("SSR worker failed to start")
|
||||
|
||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||
|
||||
def _read_responses(self) -> None:
|
||||
"""Background thread that reads JSON responses from stdout."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8")
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
|
||||
continue
|
||||
|
||||
msg_id = msg.get("id")
|
||||
|
||||
# Ready signal (id=0)
|
||||
if msg_id == 0 and msg.get("ready"):
|
||||
self._ready.set()
|
||||
continue
|
||||
|
||||
if msg_id is not None and msg_id in self._pending:
|
||||
self._results[msg_id] = msg
|
||||
self._pending[msg_id].set()
|
||||
except Exception:
|
||||
logger.warning("SSR reader thread exited", exc_info=True)
|
||||
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
|
||||
Args:
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
RenderResult with the HTML string.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
self._counter += 1
|
||||
msg_id = self._counter
|
||||
|
||||
event = threading.Event()
|
||||
self._pending[msg_id] = event
|
||||
|
||||
request = json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "render",
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||
with self._write_lock:
|
||||
try:
|
||||
self._proc.stdin.write(request.encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
self._pending.pop(msg_id, None)
|
||||
raise RuntimeError(f"SSR worker pipe broken: {e}")
|
||||
|
||||
if not event.wait(self._timeout):
|
||||
self._pending.pop(msg_id, None)
|
||||
raise TimeoutError(
|
||||
f"SSR render of '{file}' timed out after {self._timeout}s"
|
||||
)
|
||||
|
||||
self._pending.pop(msg_id, None)
|
||||
result = self._results.pop(msg_id)
|
||||
|
||||
if "error" in result:
|
||||
raise RuntimeError(f"SSR render failed: {result['error']}")
|
||||
|
||||
return RenderResult(html=result["html"])
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the Bun subprocess."""
|
||||
if self._proc is not None:
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
|
||||
|
||||
def test_jwt_expired_with_session(self):
|
||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||
# Create token with past expiration by mocking time (minting lives in the core now)
|
||||
with patch("mizan_core.auth.jwt.time.time", return_value=0):
|
||||
# Create token with past expiration by mocking time
|
||||
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||
tokens = create_token_pair(
|
||||
self.user.pk,
|
||||
self.session_key,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Upload dispatch — multipart RPC binds files into Upload fields and enforces
|
||||
the declarative `File(...)` constraints."""
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan import Upload, File
|
||||
from mizan.client import client
|
||||
from mizan.client.executor import function_call_view
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
|
||||
class AvatarOut(BaseModel):
|
||||
ok: bool
|
||||
size: int
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class UploadDispatchTests(TestCase):
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def _register(self):
|
||||
@client
|
||||
def set_avatar(
|
||||
request: HttpRequest,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
|
||||
) -> AvatarOut:
|
||||
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
|
||||
|
||||
register(set_avatar, "set_avatar")
|
||||
|
||||
def _post(self, args, files):
|
||||
data = {"fn": "set_avatar", "args": json.dumps(args), **files}
|
||||
request = self.factory.post("/api/mizan/call/", data) # multipart
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
return function_call_view(request)
|
||||
|
||||
def test_upload_binds_and_executes(self):
|
||||
self._register()
|
||||
png = SimpleUploadedFile("a.png", b"\x89PNG" + b"x" * 100, content_type="image/png")
|
||||
resp = self._post({"user_id": 5}, {"avatar": png})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = json.loads(resp.content)
|
||||
self.assertTrue(data["result"]["ok"])
|
||||
self.assertEqual(data["result"]["name"], "a.png")
|
||||
self.assertEqual(data["result"]["size"], 104)
|
||||
|
||||
def test_max_size_rejected(self):
|
||||
self._register()
|
||||
big = SimpleUploadedFile("b.png", b"x" * (2 * 1024 * 1024), content_type="image/png")
|
||||
resp = self._post({"user_id": 5}, {"avatar": big})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("max size", resp.content.decode())
|
||||
|
||||
def test_content_type_rejected(self):
|
||||
self._register()
|
||||
gif = SimpleUploadedFile("c.gif", b"GIF89a", content_type="image/gif")
|
||||
resp = self._post({"user_id": 5}, {"avatar": gif})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("content-type", resp.content.decode())
|
||||
Reference in New Issue
Block a user