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:
2026-06-04 14:59:53 -04:00
parent adcc027894
commit ae684a36cb
126 changed files with 1711 additions and 13265 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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:

View 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")

View File

@@ -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,

View File

@@ -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())