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:
@@ -6,7 +6,6 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PyJWT>=2.0",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
|
||||
|
||||
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from mizan_core.auth.authenticate import INVALID, AuthConfig, authenticate
|
||||
from mizan_core.auth.jwt import (
|
||||
JWTConfig,
|
||||
JWTUser,
|
||||
TokenPair,
|
||||
TokenPayload,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
create_token_pair,
|
||||
decode_token,
|
||||
refresh_tokens,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthConfig",
|
||||
"authenticate",
|
||||
"INVALID",
|
||||
"JWTConfig",
|
||||
"JWTUser",
|
||||
"TokenPair",
|
||||
"TokenPayload",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"create_token_pair",
|
||||
"decode_token",
|
||||
"refresh_tokens",
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Token → identity resolution, shared by every adapter.
|
||||
|
||||
`authenticate(headers, config)` reads `X-Mizan-Token` (MWT) first, then
|
||||
`Authorization: Bearer` (JWT), and returns an `Identity`, `None`, or the
|
||||
`INVALID` sentinel.
|
||||
|
||||
The `INVALID` sentinel is load-bearing: when a token is PRESENT but bad, the
|
||||
adapter must REJECT — never silently fall back to session auth (that would let
|
||||
a forged/expired token degrade into anonymous-or-session access). `None` means
|
||||
"no token offered" → the adapter may fall back to its own session identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, decode_token
|
||||
from mizan_core.identity import Identity
|
||||
from mizan_core.mwt import MWTUser, decode_mwt
|
||||
|
||||
|
||||
class _Invalid:
|
||||
"""Sentinel: a token was presented but failed validation."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "INVALID"
|
||||
|
||||
|
||||
INVALID = _Invalid()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfig:
|
||||
jwt: JWTConfig | None = None
|
||||
mwt_secret: str | None = None
|
||||
mwt_audience: str = "mizan"
|
||||
|
||||
|
||||
def authenticate(headers: Mapping[str, str], config: AuthConfig) -> Identity | _Invalid | None:
|
||||
"""Resolve identity from request headers. Returns Identity | INVALID | None."""
|
||||
mwt = headers.get("X-Mizan-Token") or headers.get("x-mizan-token")
|
||||
if mwt and config.mwt_secret:
|
||||
payload = decode_mwt(mwt, config.mwt_secret, audience=config.mwt_audience)
|
||||
return MWTUser(payload) if payload else INVALID
|
||||
|
||||
bearer = headers.get("Authorization") or headers.get("authorization") or ""
|
||||
if bearer.startswith("Bearer ") and config.jwt:
|
||||
payload = decode_token(bearer[7:], config.jwt, expected_type="access")
|
||||
return JWTUser(payload) if payload else INVALID
|
||||
|
||||
return None
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
JWT access/refresh tokens — adapter-agnostic (PyJWT).
|
||||
|
||||
Config is injected (`JWTConfig`) rather than read from any framework's settings.
|
||||
`validate_session` (the immediate-logout-revocation check) is Django-session-bound
|
||||
and stays in the Django adapter; `refresh_tokens` takes a `session_validator`
|
||||
callable so the core stays framework-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, NamedTuple
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JWTConfig:
|
||||
private_key: str
|
||||
public_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expires_in: int = 300
|
||||
refresh_token_expires_in: int = 604800
|
||||
|
||||
|
||||
class TokenPair(NamedTuple):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenPayload(NamedTuple):
|
||||
user_id: int | str
|
||||
session_key: str
|
||||
token_type: str
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
exp: int
|
||||
iat: int
|
||||
|
||||
|
||||
class JWTUser:
|
||||
"""Minimal `Identity` built from JWT claims — no DB query."""
|
||||
|
||||
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
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"JWTUser(id={self.id})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||
|
||||
|
||||
def _mint(user_id: int | str, session_key: str, token_type: str, ttl: int,
|
||||
config: JWTConfig, is_staff: bool, is_superuser: bool) -> str:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": session_key,
|
||||
"staff": is_staff,
|
||||
"super": is_superuser,
|
||||
"type": token_type,
|
||||
"iat": now,
|
||||
"exp": now + ttl,
|
||||
}
|
||||
return jwt.encode(payload, config.private_key, algorithm=config.algorithm)
|
||||
|
||||
|
||||
def create_access_token(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> str:
|
||||
return _mint(user_id, session_key, "access", config.access_token_expires_in,
|
||||
config, is_staff, is_superuser)
|
||||
|
||||
|
||||
def create_refresh_token(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> str:
|
||||
return _mint(user_id, session_key, "refresh", config.refresh_token_expires_in,
|
||||
config, is_staff, is_superuser)
|
||||
|
||||
|
||||
def create_token_pair(user_id, session_key, config: JWTConfig, *,
|
||||
is_staff: bool = False, is_superuser: bool = False) -> TokenPair:
|
||||
return TokenPair(
|
||||
access_token=create_access_token(user_id, session_key, config,
|
||||
is_staff=is_staff, is_superuser=is_superuser),
|
||||
refresh_token=create_refresh_token(user_id, session_key, config,
|
||||
is_staff=is_staff, is_superuser=is_superuser),
|
||||
expires_in=config.access_token_expires_in,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, config: JWTConfig, expected_type: str | None = None) -> TokenPayload | None:
|
||||
"""Decode + validate. None on invalid/expired token, or type mismatch."""
|
||||
try:
|
||||
payload = jwt.decode(token, config.public_key, algorithms=[config.algorithm])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
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 refresh_tokens(
|
||||
refresh_token: str,
|
||||
config: JWTConfig,
|
||||
session_validator: Callable[[str], bool] | None = None,
|
||||
) -> TokenPair | None:
|
||||
"""Exchange a refresh token for a new pair. None if invalid or the session is gone.
|
||||
|
||||
`session_validator(session_key) -> bool` lets the Django adapter enforce
|
||||
immediate-logout revocation; omit it (or pass a always-True) where there is
|
||||
no session store.
|
||||
"""
|
||||
payload = decode_token(refresh_token, config, expected_type="refresh")
|
||||
if payload is None:
|
||||
return None
|
||||
if session_validator is not None and not session_validator(payload.session_key):
|
||||
return None
|
||||
return create_token_pair(payload.user_id, payload.session_key, config,
|
||||
is_staff=payload.is_staff, is_superuser=payload.is_superuser)
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
Auth-guard evaluation — the adapter-agnostic core.
|
||||
|
||||
`enforce_auth` evaluates a function's `@client(auth=...)` requirement against an
|
||||
`Identity` and raises `Unauthorized`/`Forbidden` on failure. A custom `auth=callable`
|
||||
receives the adapter's NATIVE request (it may read request-specific state), passed
|
||||
through opaquely — the core never introspects it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.errors import Forbidden, InternalError, Unauthorized
|
||||
from mizan_core.identity import Identity
|
||||
|
||||
|
||||
def enforce_auth(
|
||||
identity: Identity | None,
|
||||
requirement: Any,
|
||||
native_request: Any = None,
|
||||
) -> None:
|
||||
"""Raise `Unauthorized`/`Forbidden` if `identity` fails `requirement`; else return.
|
||||
|
||||
Requirement: None | True | "required" | "staff" | "superuser" | callable(native_request)->bool.
|
||||
"""
|
||||
if requirement is None:
|
||||
return
|
||||
|
||||
if callable(requirement):
|
||||
try:
|
||||
if not requirement(native_request):
|
||||
raise Forbidden("Access denied")
|
||||
except PermissionError as e:
|
||||
raise Forbidden(str(e) or "Access denied") from e
|
||||
return
|
||||
|
||||
if not getattr(identity, "is_authenticated", False):
|
||||
raise Unauthorized("Authentication required")
|
||||
|
||||
if requirement in (True, "required"):
|
||||
return
|
||||
if requirement == "staff":
|
||||
if not getattr(identity, "is_staff", False):
|
||||
raise Forbidden("Staff access required")
|
||||
return
|
||||
if requirement == "superuser":
|
||||
if not getattr(identity, "is_superuser", False):
|
||||
raise Forbidden("Superuser access required")
|
||||
return
|
||||
|
||||
raise InternalError(f"Unknown auth requirement: {requirement!r}")
|
||||
@@ -487,10 +487,8 @@ def _create_server_function(
|
||||
# Use function name directly
|
||||
name = fn.__name__
|
||||
|
||||
# Extract type hints and signature. include_extras keeps `Annotated[...]`
|
||||
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
|
||||
# survives into the generated Input model.
|
||||
hints = get_type_hints(fn, include_extras=True)
|
||||
# Extract type hints and signature
|
||||
hints = get_type_hints(fn)
|
||||
sig = inspect.signature(fn)
|
||||
params = list(sig.parameters.items())
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"""
|
||||
The adapter-agnostic dispatch core.
|
||||
|
||||
Both `dispatch_call` (mutations/RPC) and `dispatch_context` (bundled reads) run
|
||||
the full protocol: auth → input validation → execute (`await view.acall`, which
|
||||
threadpools sync handlers) → serialize → resolve invalidation/merge → orchestrate
|
||||
origin cache. They return a `DispatchResult` the adapter renders to its native
|
||||
response. Errors raise `MizanError` (the adapter catches at its boundary).
|
||||
|
||||
The adapter owns native request parsing (multipart/JSON) and native response
|
||||
construction; it hands the core a `DispatchRequest` carrying only what the core
|
||||
reads, and renders what the core returns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from mizan_core.authguard import enforce_auth
|
||||
from mizan_core.cache.backend import CacheBackend
|
||||
from mizan_core.cache.keys import CONTEXT_KEY_PREFIX, derive_cache_key
|
||||
from mizan_core.errors import (
|
||||
BadRequest,
|
||||
InternalError,
|
||||
MizanError,
|
||||
NotFound,
|
||||
NotImplementedYet,
|
||||
ValidationFailed,
|
||||
)
|
||||
from mizan_core.identity import Identity, user_id_of
|
||||
from mizan_core.invalidation import (
|
||||
format_invalidate_header,
|
||||
resolve_invalidation,
|
||||
resolve_merges,
|
||||
)
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
|
||||
|
||||
# ─── Request / result ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchRequest:
|
||||
"""What the dispatch core reads. The adapter resolves `identity` (session OR
|
||||
token) and parses `args`/`files`; `native_request` is an opaque passthrough
|
||||
handed to `view_class(...)` and to `auth=callable`."""
|
||||
|
||||
identity: Identity | None = None
|
||||
args: dict[str, Any] | None = None
|
||||
files: dict[str, list[Any]] | None = None
|
||||
native_request: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
kind: Literal["rpc", "view", "context"] = "rpc"
|
||||
native_response: Any | None = None # view-path: the handler's own response
|
||||
data: Any | None = None # rpc: serialized payload; context: bundle dict
|
||||
body_bytes: bytes | None = None # context: canonical JSON to send/cache
|
||||
cache_status: str | None = None # context: "HIT" | "MISS" | None
|
||||
invalidate: list[Any] | None = None
|
||||
merge: list[dict[str, Any]] | None = None
|
||||
invalidate_header: str | None = None
|
||||
|
||||
|
||||
# ─── Cache orchestration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CacheOrchestrator:
|
||||
"""Origin-side cache, backend + secret injected by the adapter (config seam)."""
|
||||
|
||||
def __init__(self, backend: CacheBackend | None, secret: str | None):
|
||||
self.backend = backend
|
||||
self.secret = secret
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.backend is not None and bool(self.secret)
|
||||
|
||||
def get(self, context: str, params: dict[str, Any], user_id: str | None, rev: int) -> bytes | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
return self.backend.get(derive_cache_key(self.secret, context, params, user_id, rev))
|
||||
|
||||
def put(self, context: str, params: dict[str, Any], value: bytes, user_id: str | None, rev: int) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self.backend.set(derive_cache_key(self.secret, context, params, user_id, rev), value)
|
||||
|
||||
def purge(self, invalidate: list[Any], user_id: str | None) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
for entry in invalidate:
|
||||
if isinstance(entry, str):
|
||||
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{entry}:")
|
||||
elif isinstance(entry, dict):
|
||||
ctx = entry["context"]
|
||||
params = entry.get("params")
|
||||
if params:
|
||||
self.backend.delete(derive_cache_key(self.secret, ctx, params, user_id, 0))
|
||||
else:
|
||||
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{ctx}:")
|
||||
|
||||
|
||||
# ─── Shared dispatch helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_function(fn_name: str) -> Any:
|
||||
view_class = get_function(fn_name)
|
||||
if view_class is None:
|
||||
raise NotFound("Function not found")
|
||||
if getattr(view_class, "_meta", {}).get("private"):
|
||||
from mizan_core.errors import Forbidden
|
||||
raise Forbidden("Function is not client-callable")
|
||||
return view_class
|
||||
|
||||
|
||||
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
||||
"""Validate `input_data` against the function's Input model."""
|
||||
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
||||
return None
|
||||
required = [name for name, f in input_cls.model_fields.items() if f.is_required()]
|
||||
if not input_data:
|
||||
if required:
|
||||
raise ValidationFailed(
|
||||
"Input validation failed",
|
||||
details={"fields": {name: ["Field required"] for name in required}},
|
||||
)
|
||||
return input_cls()
|
||||
if not isinstance(input_data, dict):
|
||||
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||
try:
|
||||
return input_cls(**input_data)
|
||||
except ValidationError as e:
|
||||
raise ValidationFailed("Input validation failed", details={"errors": e.errors()}) from e
|
||||
|
||||
|
||||
def _serialize(result: Any) -> Any:
|
||||
return to_jsonable_python(result)
|
||||
|
||||
|
||||
async def _run(view: Any, validated: Any) -> Any:
|
||||
try:
|
||||
return await view.acall(validated)
|
||||
except NotImplementedError as e:
|
||||
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||
except MizanError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from e
|
||||
|
||||
|
||||
def _canonical_bytes(data: Any) -> bytes:
|
||||
return json.dumps(data, sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
# ─── Entry points ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def dispatch_call(req: DispatchRequest, fn_name: str, cache: CacheOrchestrator) -> DispatchResult:
|
||||
"""Mutation / RPC dispatch."""
|
||||
view_class = _resolve_function(fn_name)
|
||||
meta = getattr(view_class, "_meta", {})
|
||||
enforce_auth(req.identity, meta.get("auth"), req.native_request)
|
||||
|
||||
view = view_class(req.native_request)
|
||||
validated = _validate_input(view.Input, req.args)
|
||||
result = await _run(view, validated)
|
||||
|
||||
invalidate = resolve_invalidation(view_class, req.args)
|
||||
header = format_invalidate_header(invalidate) if invalidate else None
|
||||
if invalidate:
|
||||
cache.purge(invalidate, user_id_of(req.identity))
|
||||
|
||||
if meta.get("view_path"):
|
||||
# Handler returned its own native response; carry it through + the header.
|
||||
return DispatchResult(kind="view", native_response=result,
|
||||
invalidate=invalidate, invalidate_header=header)
|
||||
|
||||
serialized = _serialize(result)
|
||||
return DispatchResult(
|
||||
kind="rpc",
|
||||
data=serialized,
|
||||
invalidate=invalidate,
|
||||
merge=resolve_merges(view_class, req.args, serialized),
|
||||
invalidate_header=header,
|
||||
)
|
||||
|
||||
|
||||
def _effective_policy(fn_names: list[str]) -> tuple[int, int | bool]:
|
||||
"""(effective_rev, effective_cache) across a context's functions."""
|
||||
rev = 0
|
||||
cache_policy: int | bool = True # True=forever, False=no-store, int=TTL
|
||||
for fn_name in fn_names:
|
||||
fn_cls = get_function(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
m = getattr(fn_cls, "_meta", {})
|
||||
rev = max(rev, m.get("rev", 0))
|
||||
fn_cache = m.get("cache", True)
|
||||
if fn_cache is False:
|
||||
return rev, False
|
||||
if isinstance(fn_cache, int):
|
||||
cache_policy = fn_cache if cache_policy is True else min(cache_policy, fn_cache)
|
||||
return rev, cache_policy
|
||||
|
||||
|
||||
async def dispatch_context(req: DispatchRequest, context_name: str, cache: CacheOrchestrator) -> DispatchResult:
|
||||
"""Bundled context read with origin-cache get/put."""
|
||||
groups = get_context_groups()
|
||||
fn_names = groups.get(context_name)
|
||||
if not fn_names:
|
||||
raise NotFound(f"Context '{context_name}' not found")
|
||||
|
||||
params = req.args or {}
|
||||
rev, cache_policy = _effective_policy(fn_names)
|
||||
user_id = user_id_of(req.identity)
|
||||
use_cache = cache.enabled and cache_policy is not False
|
||||
|
||||
if use_cache:
|
||||
cached = cache.get(context_name, params, user_id, rev)
|
||||
if cached is not None:
|
||||
return DispatchResult(kind="context", body_bytes=cached, cache_status="HIT")
|
||||
|
||||
bundle: dict[str, Any] = {}
|
||||
for fn_name in fn_names:
|
||||
view_class = _resolve_function(fn_name)
|
||||
enforce_auth(req.identity, getattr(view_class, "_meta", {}).get("auth"), req.native_request)
|
||||
view = view_class(req.native_request)
|
||||
validated = _validate_input(view.Input, {k: v for k, v in params.items() if _declares(view.Input, k)})
|
||||
bundle[fn_name] = _serialize(await _run(view, validated))
|
||||
|
||||
body = _canonical_bytes(bundle)
|
||||
if use_cache:
|
||||
cache.put(context_name, params, body, user_id, rev)
|
||||
return DispatchResult(kind="context", data=bundle, body_bytes=body,
|
||||
cache_status="MISS" if use_cache else None)
|
||||
|
||||
|
||||
def _declares(input_cls: Any, name: str) -> bool:
|
||||
return bool(
|
||||
input_cls and input_cls is not BaseModel
|
||||
and getattr(input_cls, "model_fields", None)
|
||||
and name in input_cls.model_fields
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Canonical protocol-level error taxonomy.
|
||||
|
||||
Dispatch raises these typed exceptions; each backend adapter renders them to
|
||||
its native response (Django `JsonResponse`, FastAPI exception handler, …). The
|
||||
shared dispatch core never returns error envelopes — it raises, and the adapter
|
||||
catches at its boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
BAD_REQUEST = "BAD_REQUEST"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
FORBIDDEN = "FORBIDDEN"
|
||||
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
|
||||
|
||||
STATUS = {
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
ErrorCode.BAD_REQUEST: 400,
|
||||
ErrorCode.VALIDATION_ERROR: 422,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.FORBIDDEN: 403,
|
||||
ErrorCode.NOT_IMPLEMENTED: 501,
|
||||
ErrorCode.INTERNAL_ERROR: 500,
|
||||
}
|
||||
|
||||
|
||||
class MizanError(Exception):
|
||||
"""Base for protocol-level dispatch errors."""
|
||||
|
||||
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
||||
|
||||
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
return STATUS[self.code]
|
||||
|
||||
|
||||
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
|
||||
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
|
||||
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
||||
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
|
||||
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
|
||||
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
||||
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
The minimal identity contract the dispatch core reads.
|
||||
|
||||
Auth-guard evaluation and per-user cache scoping need exactly these four
|
||||
attributes — nothing about how the identity was established. Django's session
|
||||
`User`, `JWTUser`, `MWTUser`, and any token-user an adapter constructs all
|
||||
satisfy this structurally; no inheritance required.
|
||||
|
||||
`get_all_permissions()` (Django ORM) is deliberately NOT here — the MWT
|
||||
permission-key is a Django-side concern, and adding it would force every
|
||||
adapter to implement a Django-shaped method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Identity(Protocol):
|
||||
is_authenticated: bool
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
|
||||
@property
|
||||
def pk(self) -> object | None: ... # str | int | None; cache scoping stringifies it
|
||||
|
||||
|
||||
def user_id_of(identity: Identity | None) -> str | None:
|
||||
"""The cache-scoping user id — `str(pk)`, or None for anonymous/no-pk."""
|
||||
pk = getattr(identity, "pk", None)
|
||||
return str(pk) if pk is not None else None
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Server-driven invalidation + merge resolution — the adapter-agnostic core.
|
||||
|
||||
This is the canonical implementation (formerly housed in the Django executor).
|
||||
Every adapter calls `resolve_invalidation` / `resolve_merges` / `format_invalidate_header`
|
||||
so the wire shape is identical across backends.
|
||||
|
||||
Invalidation entries take one of two shapes:
|
||||
- a bare context/function name string → broad invalidation
|
||||
- {"context": name, "params": {...}} → scoped invalidation
|
||||
Function-level `affects=` resolves to the function NAME as the key (v1 refetches
|
||||
the whole context anyway).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function
|
||||
from mizan_core.type_utils import types_match_for_merge
|
||||
|
||||
|
||||
__all__ = ["resolve_invalidation", "resolve_merges", "format_invalidate_header"]
|
||||
|
||||
|
||||
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||
"""Classify an affects target → ("context", name, None) | ("function", name, ctx)."""
|
||||
groups = get_context_groups()
|
||||
if target_name in groups:
|
||||
return ("context", target_name, None)
|
||||
for ctx_name, fn_names in groups.items():
|
||||
if target_name in fn_names:
|
||||
return ("function", target_name, ctx_name)
|
||||
# Unknown — treat as a context name (non-context fn, or not-yet-registered).
|
||||
return ("context", target_name, None)
|
||||
|
||||
|
||||
def _context_param_names(context_name: str) -> set[str]:
|
||||
"""Union of Input field names across the functions in a context."""
|
||||
param_names: set[str] = set()
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
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,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""Three-tier auto-scoping over `@client(affects=...)`. None if nothing to invalidate.
|
||||
|
||||
Tier 1: arg-name matching against the context's params → scoped entry.
|
||||
Tier 2: auth inference — Edge-side, not handled here.
|
||||
Tier 3: broad fallback — bare name.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
affects = getattr(view_class, "_meta", {}).get("affects")
|
||||
if not affects:
|
||||
return None
|
||||
|
||||
result: list[str | dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for target in affects:
|
||||
if target["type"] == "context":
|
||||
target_name = target["name"]
|
||||
elif target["type"] == "function" and target.get("context"):
|
||||
target_name = target["name"]
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_name in seen:
|
||||
continue
|
||||
seen.add(target_name)
|
||||
|
||||
resolved = _resolve_affects_target(target_name)
|
||||
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||
|
||||
if input_data and ctx_for_params:
|
||||
context_params = _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
|
||||
|
||||
result.append(target_name)
|
||||
|
||||
return result or None
|
||||
|
||||
|
||||
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||
"""The unique function-name slot in a context whose return type matches the mutation output."""
|
||||
if mutation_output is None:
|
||||
return None
|
||||
matches: list[str] = []
|
||||
for fn_name in get_context_groups().get(context_name, []):
|
||||
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 types_match_for_merge(fn_output, mutation_output):
|
||||
matches.append(fn_name)
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def resolve_merges(
|
||||
view_class: type | None,
|
||||
input_data: dict[str, Any] | None,
|
||||
result_data: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Build the `merge` list from `@client(merge=...)`. None when no targets resolve.
|
||||
|
||||
Each entry is `{context, slot, value, params?}`; `slot` is the context-function
|
||||
whose return type matches the mutation output (server-side type-checked routing,
|
||||
no client shape inference). Ambiguous/unmatched targets are dropped.
|
||||
"""
|
||||
if view_class is None:
|
||||
return None
|
||||
targets = getattr(view_class, "_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)
|
||||
if slot is None:
|
||||
continue
|
||||
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||
if input_data:
|
||||
matched = {k: v for k, v in input_data.items() if k in _context_param_names(ctx_name)}
|
||||
if matched:
|
||||
entry["params"] = matched
|
||||
out.append(entry)
|
||||
return out or None
|
||||
|
||||
|
||||
def format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
||||
"""Serialize invalidation targets to the `X-Mizan-Invalidate` header value.
|
||||
|
||||
Comma-separated contexts; semicolon-separated URL-encoded params per context.
|
||||
["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"
|
||||
"""
|
||||
parts: list[str] = []
|
||||
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)
|
||||
@@ -17,7 +17,6 @@ KDL grammar — locked contract:
|
||||
| list { <type-child> }
|
||||
| optional { <type-child> }
|
||||
| enum "<v1>" "<v2>" ...
|
||||
| upload max-size=<int>? { content-type "<mime>" ... }
|
||||
}
|
||||
...
|
||||
}
|
||||
@@ -73,7 +72,6 @@ from pydantic_core import PydanticUndefined
|
||||
|
||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
from mizan_core.upload import File, classify_upload
|
||||
|
||||
|
||||
__all__ = ["build_ir"]
|
||||
@@ -246,34 +244,6 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
|
||||
_emit_type_child(alias_block, annotation, named_types)
|
||||
|
||||
|
||||
def _emit_upload_node(block: _Block, spec: File | None) -> None:
|
||||
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
|
||||
props: dict[str, str] = {}
|
||||
if spec is not None and spec.max_size is not None:
|
||||
props["max-size"] = repr(spec.max_size)
|
||||
if spec is not None and spec.content_types:
|
||||
with block.node("upload", **props) as up:
|
||||
for ct in spec.content_types:
|
||||
up.leaf("content-type", _kdl_string(ct))
|
||||
else:
|
||||
block.leaf("upload", **props)
|
||||
|
||||
|
||||
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
|
||||
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
|
||||
if is_optional and is_list:
|
||||
with block.node("optional") as opt, opt.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
elif is_optional:
|
||||
with block.node("optional") as opt:
|
||||
_emit_upload_node(opt, spec)
|
||||
elif is_list:
|
||||
with block.node("list") as lst:
|
||||
_emit_upload_node(lst, spec)
|
||||
else:
|
||||
_emit_upload_node(block, spec)
|
||||
|
||||
|
||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||
with block.node("struct") as struct_block:
|
||||
@@ -289,11 +259,7 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
|
||||
props["default"] = _kdl_value(default)
|
||||
|
||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||
is_upload, is_list, is_optional, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
_emit_upload_child(field_block, is_list, is_optional, spec)
|
||||
else:
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||
|
||||
|
||||
class _StructShape:
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Edge-manifest derivation — the AFI-common source of truth.
|
||||
|
||||
The Edge manifest is a static JSON mapping contexts to URL patterns, params, and
|
||||
cache/render policy. Mizan Edge reads it at deploy time to drive CDN cache
|
||||
purging: when it receives `X-Mizan-Invalidate: user;user_id=5` it looks up
|
||||
`user` in the manifest, resolves the page routes with the params, and purges
|
||||
both the resolved URLs and the context endpoint.
|
||||
|
||||
The manifest is *derived from the registry* — the same `@client` metadata every
|
||||
adapter populates — so its derivation is AFI-common, not framework-bound. It
|
||||
lives here in the core; each adapter exposes it (a callable, a CLI entry) over
|
||||
its own surface. Django's `export_edge_manifest` command and the FastAPI
|
||||
console entry both call `generate_edge_manifest`; there is one derivation.
|
||||
|
||||
`render_strategy` is computed here too: a context whose params overlap
|
||||
`USER_SCOPED_PARAMS` is `dynamic_cached` (per-user at the edge); one whose
|
||||
params don't is `psr` (one shared pre-rendered artifact, re-rendered on
|
||||
mutation). That single rule is what the `psr` capability checks for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function, get_all_functions
|
||||
|
||||
|
||||
__all__ = [
|
||||
"USER_SCOPED_PARAMS",
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
# A context is per-user (and so must be `dynamic_cached` at the edge) when any of
|
||||
# its params identifies a user. A context with no such param renders one shared
|
||||
# artifact — `psr`. This set is the entire `render_strategy` decision.
|
||||
USER_SCOPED_PARAMS: frozenset[str] = frozenset({"user_id", "user", "owner_id", "account_id"})
|
||||
|
||||
|
||||
def _input_param_names(fn_cls: Any) -> set[str]:
|
||||
"""The declared input field names of a registered function (empty if none)."""
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
return set(input_cls.model_fields.keys())
|
||||
return set()
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Derive the Edge manifest from the function registry.
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default ``/api/mizan``).
|
||||
view_urls: Optional extra page routes per context for Edge to purge,
|
||||
beyond the routes declared on view-path functions.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable manifest: ``{"version", "contexts", "mutations"}``.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
all_functions = get_all_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
|
||||
|
||||
param_names |= _input_param_names(fn_cls)
|
||||
|
||||
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)
|
||||
|
||||
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(param_names),
|
||||
"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 a param of an affected
|
||||
# context. These are the keys Edge can resolve to scope the purge.
|
||||
fn_params = _input_param_names(fn_cls)
|
||||
if fn_params:
|
||||
auto_scoped: list[str] = []
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_param_names: set[str] = set()
|
||||
for ctx_fn_name in groups.get(ctx_name, []):
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls is not None:
|
||||
ctx_param_names |= _input_param_names(ctx_fn_cls)
|
||||
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 | None = 2,
|
||||
) -> str:
|
||||
"""JSON-serialize the Edge manifest (keys sorted for deterministic output)."""
|
||||
return json.dumps(
|
||||
generate_edge_manifest(base_url, view_urls), indent=indent, sort_keys=True
|
||||
)
|
||||
@@ -1,15 +1,12 @@
|
||||
"""
|
||||
Mizan core registry — function and composition registration with an
|
||||
extension hook for the AFI-common capabilities that need their own
|
||||
sub-registry (channels/WebSocket, forms, shapes) to plug into.
|
||||
extension hook for backend-specific registries (channels, forms, etc.)
|
||||
to plug into.
|
||||
|
||||
This is the framework-agnostic registry. The extension points
|
||||
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
|
||||
a binding for each, on its own stack — Django Channels or a native
|
||||
WebSocket route; Django Forms or Pydantic; django-readers or the project's
|
||||
ORM. The capability is common; the binding is per-stack. Each adapter wires
|
||||
its binding so the unified schema export sees it; an unwired one is a gap on
|
||||
the capability-parity board (`tests/afi/`), not a framework-specific feature.
|
||||
This is the framework-agnostic registry. Backends own their own
|
||||
type-specific registries (channels in Django Channels, forms in Django
|
||||
Forms, websockets in FastAPI, etc.) and register them as extensions
|
||||
here so the unified schema export can include them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
mizan_core.ssr — framework-agnostic server-side rendering.
|
||||
|
||||
`SSRBridge` manages a persistent Bun subprocess that renders React components to
|
||||
HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle;
|
||||
adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's
|
||||
`SSRRenderer`).
|
||||
"""
|
||||
|
||||
from mizan_core.ssr.bridge import RenderResult, SSRBridge
|
||||
|
||||
__all__ = ["SSRBridge", "RenderResult"]
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
SSR Bridge — manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
|
||||
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
|
||||
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
|
||||
render path — so the subprocess lifecycle and wire protocol are authored once.
|
||||
|
||||
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 worker threads 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")
|
||||
@@ -1,216 +0,0 @@
|
||||
"""
|
||||
Mizan Upload — first-class binary input for ``@client`` functions.
|
||||
|
||||
``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
|
||||
parameter makes a function multipart-aware end to end: the generated client
|
||||
switches that call to ``multipart/form-data``, and each backend adapter parses
|
||||
the file part and binds a uniform :class:`UploadedFile` into the function's
|
||||
``Input``. Constraints declared via :class:`File` are enforced at dispatch.
|
||||
|
||||
from typing import Annotated
|
||||
from mizan import client, Upload, File
|
||||
|
||||
@client(affects=UserContext)
|
||||
def set_avatar(
|
||||
request,
|
||||
user_id: int,
|
||||
avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
|
||||
) -> dict:
|
||||
avatar.save(f"/media/{user_id}.png")
|
||||
return {"ok": True}
|
||||
|
||||
Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
|
||||
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
|
||||
carries the declarative constraints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import GetCoreSchemaHandler
|
||||
from pydantic_core import core_schema
|
||||
|
||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Upload",
|
||||
"UploadedFile",
|
||||
"File",
|
||||
"parse_size",
|
||||
"validate_upload",
|
||||
"classify_upload",
|
||||
"upload_fields",
|
||||
"bind_uploads",
|
||||
]
|
||||
|
||||
|
||||
_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}
|
||||
|
||||
|
||||
def parse_size(value: int | str) -> int:
|
||||
"""Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = value.strip().upper()
|
||||
for unit, mult in _SIZE_UNITS.items():
|
||||
if s.endswith(unit):
|
||||
return int(float(s[: -len(unit)].strip()) * mult)
|
||||
return int(s)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class File:
|
||||
"""Declarative constraints for an ``Upload`` field, placed in ``Annotated``.
|
||||
|
||||
``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
|
||||
``content_types`` is a list of allowed MIME types; an entry ending in
|
||||
``/*`` (e.g. ``"image/*"``) matches any subtype.
|
||||
"""
|
||||
|
||||
max_size: int | str | None = None
|
||||
content_types: tuple[str, ...] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_size is not None:
|
||||
object.__setattr__(self, "max_size", parse_size(self.max_size))
|
||||
if self.content_types is not None and not isinstance(self.content_types, tuple):
|
||||
object.__setattr__(self, "content_types", tuple(self.content_types))
|
||||
|
||||
|
||||
class UploadedFile:
|
||||
"""Uniform file handle handed to ``@client`` functions, adapter-agnostic.
|
||||
|
||||
Backends construct this from their native upload object (Django
|
||||
``UploadedFile``, Starlette ``UploadFile``) so a function body stays
|
||||
portable across adapters.
|
||||
"""
|
||||
|
||||
__slots__ = ("filename", "content_type", "_data")
|
||||
|
||||
def __init__(self, filename: str | None, content_type: str | None, data: bytes):
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def save(self, path: str | os.PathLike) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(self._data)
|
||||
|
||||
|
||||
class Upload:
|
||||
"""Pydantic-composable marker type for a binary file input.
|
||||
|
||||
At validation time it accepts any :class:`UploadedFile` (the adapter has
|
||||
already bound the multipart part). The IR emitter recognizes Upload-typed
|
||||
fields and emits an ``upload`` node.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source: Any, handler: GetCoreSchemaHandler
|
||||
) -> core_schema.CoreSchema:
|
||||
return core_schema.no_info_plain_validator_function(cls._validate)
|
||||
|
||||
@staticmethod
|
||||
def _validate(value: Any) -> UploadedFile:
|
||||
if isinstance(value, UploadedFile):
|
||||
return value
|
||||
raise ValueError("expected an uploaded file")
|
||||
|
||||
|
||||
def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
|
||||
if not content_type:
|
||||
return False
|
||||
for ct in allowed:
|
||||
if ct == content_type:
|
||||
return True
|
||||
if ct.endswith("/*") and content_type.startswith(ct[:-1]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
|
||||
"""Enforce declared constraints. Returns an error message, or ``None`` if ok."""
|
||||
if spec is None:
|
||||
return None
|
||||
if spec.max_size is not None and file.size > spec.max_size:
|
||||
return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
|
||||
if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
|
||||
return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
|
||||
return None
|
||||
|
||||
|
||||
# ─── Field classification + binding (shared by every backend adapter) ─────────
|
||||
|
||||
|
||||
def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
|
||||
"""Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
|
||||
if hasattr(annotation, "__metadata__"):
|
||||
spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
|
||||
return annotation.__origin__, spec
|
||||
return annotation, None
|
||||
|
||||
|
||||
def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
|
||||
"""Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.
|
||||
|
||||
Composes through ``Optional[...]``, ``list[...]``, and
|
||||
``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
|
||||
wherever it appears (Pydantic lifts a top-level marker into
|
||||
``field_info.metadata``; nested markers stay inside the annotation).
|
||||
"""
|
||||
spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
|
||||
ann = field_info.annotation
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
ann, is_optional = extract_optional(ann)
|
||||
ann, s = _strip_annotated_meta(ann); spec = spec or s
|
||||
elem = extract_list_element(ann)
|
||||
is_list = elem is not None
|
||||
if is_list:
|
||||
ann, s = _strip_annotated_meta(elem); spec = spec or s
|
||||
return ann is Upload, is_list, is_optional, spec
|
||||
|
||||
|
||||
def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
|
||||
"""Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
|
||||
out: dict[str, tuple[bool, File | None]] = {}
|
||||
for name, field_info in model.model_fields.items():
|
||||
is_upload, is_list, _is_opt, spec = classify_upload(field_info)
|
||||
if is_upload:
|
||||
out[name] = (is_list, spec)
|
||||
return out
|
||||
|
||||
|
||||
def bind_uploads(
|
||||
input_cls: Any,
|
||||
args: dict[str, Any],
|
||||
files: dict[str, list[UploadedFile]],
|
||||
) -> str | None:
|
||||
"""Place uploaded files into ``args`` by field name, enforcing constraints.
|
||||
|
||||
Mutates ``args`` in place. ``files`` maps a field name to the parts received
|
||||
for it (an array field receives several). Returns an error message on the
|
||||
first constraint violation, else ``None``. Absent files are left for
|
||||
Pydantic's required/optional handling.
|
||||
"""
|
||||
for fname, (is_list, spec) in upload_fields(input_cls).items():
|
||||
bucket = files.get(fname) or []
|
||||
if not bucket:
|
||||
continue
|
||||
for f in bucket:
|
||||
err = validate_upload(f, spec)
|
||||
if err is not None:
|
||||
return f"{fname}: {err}"
|
||||
args[fname] = list(bucket) if is_list else bucket[0]
|
||||
return None
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Unit tests for the adapter-agnostic dispatch core."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan_core.auth import AuthConfig, JWTConfig, INVALID, authenticate, create_access_token
|
||||
from mizan_core.authguard import enforce_auth
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
||||
from mizan_core.errors import Forbidden, Unauthorized
|
||||
from mizan_core.invalidation import format_invalidate_header, resolve_invalidation
|
||||
from mizan_core.registry import clear_registry, register
|
||||
|
||||
|
||||
class Ident:
|
||||
def __init__(self, authed=True, staff=False, su=False, pk=1):
|
||||
self.is_authenticated = authed
|
||||
self.is_staff = staff
|
||||
self.is_superuser = su
|
||||
self.pk = pk
|
||||
|
||||
|
||||
# ─── authguard ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_auth_required_anonymous():
|
||||
with pytest.raises(Unauthorized):
|
||||
enforce_auth(None, True)
|
||||
|
||||
|
||||
def test_auth_required_authenticated():
|
||||
enforce_auth(Ident(), True) # no raise
|
||||
|
||||
|
||||
def test_auth_staff_denied_then_allowed():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(staff=False), "staff")
|
||||
enforce_auth(Ident(staff=True), "staff")
|
||||
|
||||
|
||||
def test_auth_superuser():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(su=False), "superuser")
|
||||
enforce_auth(Ident(su=True), "superuser")
|
||||
|
||||
|
||||
def test_auth_callable_false_and_raise():
|
||||
with pytest.raises(Forbidden):
|
||||
enforce_auth(Ident(), lambda r: False)
|
||||
with pytest.raises(Forbidden, match="custom"):
|
||||
enforce_auth(Ident(), lambda r: (_ for _ in ()).throw(PermissionError("custom")))
|
||||
|
||||
|
||||
# ─── authenticate / INVALID sentinel ────────────────────────────────────────
|
||||
|
||||
|
||||
def _cfg():
|
||||
return AuthConfig(jwt=JWTConfig(private_key="k" * 32, public_key="k" * 32))
|
||||
|
||||
|
||||
def test_authenticate_jwt_ok():
|
||||
cfg = _cfg()
|
||||
tok = create_access_token("7", "sess", cfg.jwt, is_staff=True)
|
||||
ident = authenticate({"Authorization": f"Bearer {tok}"}, cfg)
|
||||
assert ident.pk == 7 and ident.is_staff and ident.is_authenticated
|
||||
|
||||
|
||||
def test_authenticate_bad_token_is_invalid_sentinel():
|
||||
assert authenticate({"Authorization": "Bearer garbage"}, _cfg()) is INVALID
|
||||
|
||||
|
||||
def test_authenticate_no_token_is_none():
|
||||
assert authenticate({}, _cfg()) is None
|
||||
|
||||
|
||||
# ─── invalidation + header ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_invalidation_three_tier_and_header():
|
||||
clear_registry()
|
||||
UserCtx = "user"
|
||||
|
||||
class Out(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client(context=UserCtx)
|
||||
def user_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(affects=UserCtx)
|
||||
def update_profile(request, user_id: int, name: str) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(update_profile, "update_profile")
|
||||
|
||||
# Tier 1: user_id matches context param → scoped
|
||||
inv = resolve_invalidation(update_profile, {"user_id": 5, "name": "x"})
|
||||
assert inv == [{"context": "user", "params": {"user_id": 5}}]
|
||||
assert format_invalidate_header(inv) == "user;user_id=5"
|
||||
|
||||
# Tier 3: no matching param → broad
|
||||
inv2 = resolve_invalidation(update_profile, {"name": "x"})
|
||||
assert inv2 == ["user"]
|
||||
clear_registry()
|
||||
|
||||
|
||||
# ─── dispatch_call end to end ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dispatch_call_auth_and_invalidation():
|
||||
clear_registry()
|
||||
|
||||
class Out(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
@client(affects="user", auth="staff")
|
||||
def secure_update(request, user_id: int) -> Out:
|
||||
return Out(ok=True)
|
||||
|
||||
register(user_profile, "user_profile")
|
||||
register(secure_update, "secure_update")
|
||||
|
||||
cache = CacheOrchestrator(None, None)
|
||||
|
||||
# non-staff rejected
|
||||
with pytest.raises(Forbidden):
|
||||
asyncio.run(dispatch_call(
|
||||
DispatchRequest(identity=Ident(staff=False), args={"user_id": 1}),
|
||||
"secure_update", cache,
|
||||
))
|
||||
|
||||
# staff passes, invalidation resolved
|
||||
res = asyncio.run(dispatch_call(
|
||||
DispatchRequest(identity=Ident(staff=True), args={"user_id": 1}),
|
||||
"secure_update", cache,
|
||||
))
|
||||
assert res.kind == "rpc" and res.data == {"ok": True}
|
||||
assert res.invalidate == [{"context": "user", "params": {"user_id": 1}}]
|
||||
assert res.invalidate_header == "user;user_id=1"
|
||||
clear_registry()
|
||||
@@ -26,15 +26,6 @@ pub struct FunctionArgs {
|
||||
pub merge: Vec<Path>,
|
||||
pub websocket: bool,
|
||||
pub private: bool,
|
||||
/// `auth = "required" | "staff" | "superuser"` (or bare `auth` ⇒
|
||||
/// "required") — the `@client(auth=...)` guard. Bare-true and the string
|
||||
/// `"required"` both mean "must be authenticated".
|
||||
pub auth: Option<String>,
|
||||
/// `form_name = "..."` + `form_role = "schema"|"validate"|"submit"` — the
|
||||
/// Forms binding's per-endpoint metadata, mirroring the Django form
|
||||
/// `_meta` keys. Carried into the IR (`is-form`/`form-name`/`form-role`).
|
||||
pub form_name: Option<String>,
|
||||
pub form_role: Option<String>,
|
||||
}
|
||||
|
||||
impl FunctionArgs {
|
||||
@@ -54,16 +45,10 @@ impl FunctionArgs {
|
||||
out.affects = collect_paths(&nv.value)?;
|
||||
} else if nv.path.is_ident("merge") {
|
||||
out.merge = collect_paths(&nv.value)?;
|
||||
} else if nv.path.is_ident("auth") {
|
||||
out.auth = Some(expect_str(&nv.value)?);
|
||||
} else if nv.path.is_ident("form_name") {
|
||||
out.form_name = Some(expect_str(&nv.value)?);
|
||||
} else if nv.path.is_ident("form_role") {
|
||||
out.form_role = Some(expect_str(&nv.value)?);
|
||||
} else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
nv.path,
|
||||
"unknown attribute key; expected one of: context, affects, merge, auth, form_name, form_role",
|
||||
"unknown attribute key; expected one of: context, affects, merge",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -72,12 +57,10 @@ impl FunctionArgs {
|
||||
out.websocket = true;
|
||||
} else if p.is_ident("private") {
|
||||
out.private = true;
|
||||
} else if p.is_ident("auth") {
|
||||
out.auth = Some("required".to_string());
|
||||
} else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
p,
|
||||
"unknown flag; expected `websocket`, `private`, or `auth`",
|
||||
"unknown flag; expected `websocket` or `private`",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -116,21 +99,6 @@ fn expect_path(expr: &Expr) -> syn::Result<Path> {
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_str(expr: &Expr) -> syn::Result<String> {
|
||||
if let Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(s),
|
||||
..
|
||||
}) = expr
|
||||
{
|
||||
Ok(s.value())
|
||||
} else {
|
||||
Err(syn::Error::new_spanned(
|
||||
expr,
|
||||
"expected a string literal (e.g. `\"staff\"`)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
|
||||
match expr {
|
||||
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
|
||||
@@ -215,11 +183,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
});
|
||||
}
|
||||
quote! {
|
||||
// The synthetic Input is only ever *deserialized* (from the call's
|
||||
// JSON args by the dispatch wrapper); it is never serialized, so it
|
||||
// derives `Deserialize` only. Dropping `Serialize` lets binary
|
||||
// field types like `Upload` (deserialize-only) participate.
|
||||
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Deserialize)]
|
||||
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
|
||||
pub struct #input_type_ident {
|
||||
#(#field_defs)*
|
||||
}
|
||||
@@ -389,20 +353,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
let output_nullable = analysis.nullable;
|
||||
let private = args.private;
|
||||
|
||||
let auth_value = match &args.auth {
|
||||
Some(a) => quote! { ::std::option::Option::Some(#a) },
|
||||
None => quote! { ::std::option::Option::None },
|
||||
};
|
||||
let is_form = args.form_name.is_some() || args.form_role.is_some();
|
||||
let form_name_value = match &args.form_name {
|
||||
Some(n) => quote! { ::std::option::Option::Some(#n) },
|
||||
None => quote! { ::std::option::Option::None },
|
||||
};
|
||||
let form_role_value = match &args.form_role {
|
||||
Some(r) => quote! { ::std::option::Option::Some(#r) },
|
||||
None => quote! { ::std::option::Option::None },
|
||||
};
|
||||
|
||||
let dispatch_body = build_dispatch(
|
||||
&item,
|
||||
&input_args,
|
||||
@@ -439,10 +389,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
|
||||
fn merge(&self) -> &'static [&'static str] { #merge_static }
|
||||
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
|
||||
fn private(&self) -> bool { #private }
|
||||
fn auth(&self) -> ::std::option::Option<&'static str> { #auth_value }
|
||||
fn is_form(&self) -> bool { #is_form }
|
||||
fn form_name(&self) -> ::std::option::Option<&'static str> { #form_name_value }
|
||||
fn form_role(&self) -> ::std::option::Option<&'static str> { #form_role_value }
|
||||
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
|
||||
|
||||
fn dispatch<'a>(
|
||||
|
||||
@@ -105,15 +105,6 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream {
|
||||
if let Some(p) = primitive_of(ty) {
|
||||
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
|
||||
}
|
||||
if is_upload(ty) {
|
||||
// An `Upload`-typed field emits the IR `upload` type-child rather than
|
||||
// a `ref`, matching the Python emitter. Constraints (`max-size`,
|
||||
// `content-type`) aren't carried in this baseline — an unconstrained
|
||||
// upload — but the wire/IR shape is the recognized `upload` node.
|
||||
return quote! {
|
||||
::mizan_core::TypeShape::Upload { max_size: ::std::option::Option::None, content_types: &[] }
|
||||
};
|
||||
}
|
||||
// Fallback: assume a user-defined struct/enum implementing MizanType.
|
||||
// The Ref name comes from `<T as MizanType>::TYPE_NAME` (associated const).
|
||||
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) }
|
||||
@@ -158,19 +149,6 @@ pub fn unwrap_btreemap_value(ty: &Type) -> Option<Type> {
|
||||
type_args.next()
|
||||
}
|
||||
|
||||
/// True if `ty` names the `mizan_core::Upload` marker (by its last path
|
||||
/// segment) — the binary file-input type.
|
||||
pub fn is_upload(ty: &Type) -> bool {
|
||||
match ty {
|
||||
Type::Path(TypePath { qself: None, path }) => path
|
||||
.segments
|
||||
.last()
|
||||
.map(|s| s.ident == "Upload")
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
|
||||
/// known primitive scalar.
|
||||
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
|
||||
|
||||
108
cores/mizan-rust/Cargo.lock
generated
108
cores/mizan-rust/Cargo.lock
generated
@@ -13,82 +13,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
@@ -104,12 +34,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linkme"
|
||||
version = "0.3.36"
|
||||
@@ -141,14 +65,11 @@ name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"hmac",
|
||||
"indoc",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -228,23 +149,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -256,24 +160,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -11,9 +11,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
async-trait = "0.1"
|
||||
mizan-macros = { path = "../mizan-rust-macros" }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
indoc = "2"
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
//! JWT + MWT — HS256 mint and verify, byte-pinned to the Python core.
|
||||
//!
|
||||
//! Pinned references:
|
||||
//! * JWT → `cores/mizan-python/src/mizan_core/auth/jwt.py`
|
||||
//! * MWT → `cores/mizan-python/src/mizan_core/mwt.py`
|
||||
//!
|
||||
//! These are RFC 7519 JWTs over HMAC-SHA256. Byte-identical output to PyJWT
|
||||
//! 2.x requires reproducing its exact serialization, which a generic JWT crate
|
||||
//! does not expose:
|
||||
//!
|
||||
//! * the JOSE **header** keys are emitted in **sorted** order with compact
|
||||
//! `(",", ":")` separators — `{"alg":"HS256","typ":"JWT"}`, or with a
|
||||
//! `kid`, `{"alg":"HS256","kid":"v1","typ":"JWT"}`;
|
||||
//! * the **payload** keys are emitted in **insertion** order (PyJWT does not
|
||||
//! sort the claims) with the same compact separators;
|
||||
//! * both segments are base64url-encoded **without padding**.
|
||||
//!
|
||||
//! So a mint here builds each segment's bytes deliberately (sorted header,
|
||||
//! ordered claims) and signs `header.payload`. `tests/token_pin.rs` pins the
|
||||
//! exact tokens against the Python reference for fixed inputs.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Current unix time in seconds — the `now` adapters pass to mint/verify when
|
||||
/// they aren't pinning a fixed clock (tests inject a fixed value for byte
|
||||
/// determinism).
|
||||
pub fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn b64url(bytes: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
|
||||
URL_SAFE_NO_PAD.decode(s).ok()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, signing_input: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC any key length");
|
||||
mac.update(signing_input.as_bytes());
|
||||
b64url(&mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Build a JOSE header for HS256 with optional `kid`, keys in sorted order
|
||||
/// (`alg` < `kid` < `typ`) and compact separators — byte-identical to PyJWT.
|
||||
fn header_json(kid: Option<&str>) -> String {
|
||||
match kid {
|
||||
Some(kid) => format!(
|
||||
"{{\"alg\":\"HS256\",\"kid\":{},\"typ\":\"JWT\"}}",
|
||||
json_str(kid)
|
||||
),
|
||||
None => "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode one JSON string literal byte-for-byte with PyJWT's serializer,
|
||||
/// which is `json.dumps` with the default `ensure_ascii=True`: short escapes
|
||||
/// for `"`, `\`, `\b\f\n\r\t`, and `\uXXXX` for the rest of the C0 range and
|
||||
/// every non-ASCII code point (surrogate pairs above the BMP).
|
||||
fn json_str(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0c}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||
let mut buf = [0u16; 2];
|
||||
for unit in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn json_bool(b: bool) -> &'static str {
|
||||
if b {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint `header.payload.signature` from a pre-serialized payload body. The
|
||||
/// payload bytes are authored by the caller so claim ordering is under exact
|
||||
/// control (PyJWT preserves insertion order).
|
||||
fn encode(secret: &str, kid: Option<&str>, payload_json: &str) -> String {
|
||||
let header = b64url(header_json(kid).as_bytes());
|
||||
let payload = b64url(payload_json.as_bytes());
|
||||
let signing_input = format!("{header}.{payload}");
|
||||
let sig = sign(secret, &signing_input);
|
||||
format!("{signing_input}.{sig}")
|
||||
}
|
||||
|
||||
/// Verify the HS256 signature over `header.payload` and return the decoded
|
||||
/// payload bytes. Constant-time-ish: recompute and compare the signature.
|
||||
fn verify_signature(secret: &str, token: &str) -> Option<Vec<u8>> {
|
||||
let mut parts = token.splitn(3, '.');
|
||||
let header_b64 = parts.next()?;
|
||||
let payload_b64 = parts.next()?;
|
||||
let sig_b64 = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let signing_input = format!("{header_b64}.{payload_b64}");
|
||||
let expected = sign(secret, &signing_input);
|
||||
// base64url of HMAC is fixed-length; a direct compare is adequate here and
|
||||
// matches the reference's PyJWT-side verification semantics.
|
||||
if !ct_eq(expected.as_bytes(), sig_b64.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
b64url_decode(payload_b64)
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Read the `kid` claim from the (unverified) JOSE header — needed before
|
||||
/// signature verification to mirror `decode_mwt`'s `get_unverified_header`.
|
||||
fn unverified_kid(token: &str) -> Option<String> {
|
||||
let header_b64 = token.split('.').next()?;
|
||||
let bytes = b64url_decode(header_b64)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
v.get("kid")
|
||||
.and_then(|k| k.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
// ─── JWT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// JWT signing/verification config — Rust analog of `JWTConfig`. HS256 only
|
||||
/// here (the byte-pinned algorithm); `private_key` doubles as the verify key.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub access_ttl: i64,
|
||||
pub refresh_ttl: i64,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn new(secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
secret: secret.into(),
|
||||
access_ttl: 300,
|
||||
refresh_ttl: 604_800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded JWT claims — Rust analog of `TokenPayload`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JwtPayload {
|
||||
pub sub: String,
|
||||
pub sid: String,
|
||||
pub staff: bool,
|
||||
pub superuser: bool,
|
||||
pub token_type: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Build the JWT claims body in PyJWT-insertion order: sub, sid, staff, super,
|
||||
/// type, iat, exp. (Matches `jwt.py::_mint`.)
|
||||
fn jwt_payload_json(
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
token_type: &str,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"sub\":{},\"sid\":{},\"staff\":{},\"super\":{},\"type\":{},\"iat\":{},\"exp\":{}}}",
|
||||
json_str(sub),
|
||||
json_str(sid),
|
||||
json_bool(staff),
|
||||
json_bool(superuser),
|
||||
json_str(token_type),
|
||||
iat,
|
||||
exp,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mint_jwt(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
token_type: &str,
|
||||
ttl: i64,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
let payload = jwt_payload_json(sub, sid, staff, superuser, token_type, now, now + ttl);
|
||||
encode(&cfg.secret, None, &payload)
|
||||
}
|
||||
|
||||
/// Mint an access token. `now` is unix-seconds (injected for determinism).
|
||||
pub fn create_access_token(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
mint_jwt(cfg, sub, sid, "access", cfg.access_ttl, staff, superuser, now)
|
||||
}
|
||||
|
||||
/// Mint a refresh token.
|
||||
pub fn create_refresh_token(
|
||||
cfg: &JwtConfig,
|
||||
sub: &str,
|
||||
sid: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
now: i64,
|
||||
) -> String {
|
||||
mint_jwt(
|
||||
cfg,
|
||||
sub,
|
||||
sid,
|
||||
"refresh",
|
||||
cfg.refresh_ttl,
|
||||
staff,
|
||||
superuser,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode + validate a JWT. `None` on a bad signature, malformed token,
|
||||
/// expiry (against `now`), or a `type` mismatch. Mirrors `decode_token`.
|
||||
pub fn decode_jwt(
|
||||
token: &str,
|
||||
cfg: &JwtConfig,
|
||||
expected_type: Option<&str>,
|
||||
now: i64,
|
||||
) -> Option<JwtPayload> {
|
||||
let payload_bytes = verify_signature(&cfg.secret, token)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
let exp = v.get("exp")?.as_i64()?;
|
||||
if now >= exp {
|
||||
return None;
|
||||
}
|
||||
let token_type = v.get("type")?.as_str()?.to_string();
|
||||
if let Some(want) = expected_type {
|
||||
if token_type != want {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(JwtPayload {
|
||||
sub: v.get("sub")?.as_str()?.to_string(),
|
||||
sid: v.get("sid")?.as_str()?.to_string(),
|
||||
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
token_type,
|
||||
iat: v.get("iat")?.as_i64()?,
|
||||
exp,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── MWT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decoded MWT claims — Rust analog of `MWTPayload`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MwtPayload {
|
||||
pub sub: String,
|
||||
pub staff: bool,
|
||||
pub superuser: bool,
|
||||
pub pkey: String,
|
||||
pub kid: String,
|
||||
pub aud: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Compute the permission-state hash — full SHA-256 hex over
|
||||
/// `"{staff}:{super}:{sorted,comma-joined perms}"`. Matches
|
||||
/// `mwt.py::compute_permission_key` byte-for-byte.
|
||||
pub fn compute_permission_key(staff: bool, superuser: bool, perms: &[String]) -> String {
|
||||
use sha2::Digest;
|
||||
let mut sorted: Vec<&String> = perms.iter().collect();
|
||||
sorted.sort();
|
||||
let staff_c = if staff { "1" } else { "0" };
|
||||
let super_c = if superuser { "1" } else { "0" };
|
||||
let joined: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
|
||||
let blob = format!("{staff_c}:{super_c}:{}", joined.join(","));
|
||||
let digest = Sha256::digest(blob.as_bytes());
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Build the MWT claims body in `create_mwt` insertion order: sub, staff,
|
||||
/// super, pkey, aud, iat, nbf, exp.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mwt_payload_json(
|
||||
sub: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
pkey: &str,
|
||||
aud: &str,
|
||||
iat: i64,
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{{\"sub\":{},\"staff\":{},\"super\":{},\"pkey\":{},\"aud\":{},\"iat\":{},\"nbf\":{},\"exp\":{}}}",
|
||||
json_str(sub),
|
||||
json_bool(staff),
|
||||
json_bool(superuser),
|
||||
json_str(pkey),
|
||||
json_str(aud),
|
||||
iat,
|
||||
nbf,
|
||||
exp,
|
||||
)
|
||||
}
|
||||
|
||||
/// Mint an MWT from already-resolved identity fields. `pkey` is the permission
|
||||
/// hash (see `compute_permission_key`); `now` is unix-seconds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_mwt(
|
||||
secret: &str,
|
||||
sub: &str,
|
||||
staff: bool,
|
||||
superuser: bool,
|
||||
pkey: &str,
|
||||
ttl: i64,
|
||||
audience: &str,
|
||||
kid: &str,
|
||||
now: i64,
|
||||
) -> String {
|
||||
let payload = mwt_payload_json(sub, staff, superuser, pkey, audience, now, now, now + ttl);
|
||||
encode(secret, Some(kid), &payload)
|
||||
}
|
||||
|
||||
/// Decode + validate an MWT. `None` on bad signature, malformed token, expiry,
|
||||
/// not-yet-valid (`nbf`), or audience mismatch. Mirrors `decode_mwt`.
|
||||
pub fn decode_mwt(token: &str, secret: &str, audience: &str, now: i64) -> Option<MwtPayload> {
|
||||
let kid = unverified_kid(token).unwrap_or_else(|| "v1".to_string());
|
||||
let payload_bytes = verify_signature(secret, token)?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
|
||||
let exp = v.get("exp")?.as_i64()?;
|
||||
if now >= exp {
|
||||
return None;
|
||||
}
|
||||
if let Some(nbf) = v.get("nbf").and_then(|n| n.as_i64()) {
|
||||
if now < nbf {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let aud = v.get("aud").and_then(|a| a.as_str()).unwrap_or("");
|
||||
if aud != audience {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(MwtPayload {
|
||||
sub: v.get("sub")?.as_str()?.to_string(),
|
||||
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
pkey: v
|
||||
.get("pkey")
|
||||
.and_then(|p| p.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
kid,
|
||||
aud: audience.to_string(),
|
||||
iat: v.get("iat")?.as_i64()?,
|
||||
exp,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Identity + auth-guard enforcement ───────────────────────────────────────
|
||||
|
||||
/// The identity a token resolves to — Rust analog of `Identity`. `None`
|
||||
/// (anonymous) and `Invalid` (a present-but-bad token) are distinct: the
|
||||
/// adapter must REJECT on `Invalid`, never silently downgrade to anonymous.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
pub user_id: String,
|
||||
pub is_staff: bool,
|
||||
pub is_superuser: bool,
|
||||
}
|
||||
|
||||
impl From<&JwtPayload> for Identity {
|
||||
fn from(p: &JwtPayload) -> Self {
|
||||
Self {
|
||||
user_id: p.sub.clone(),
|
||||
is_staff: p.staff,
|
||||
is_superuser: p.superuser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&MwtPayload> for Identity {
|
||||
fn from(p: &MwtPayload) -> Self {
|
||||
Self {
|
||||
user_id: p.sub.clone(),
|
||||
is_staff: p.staff,
|
||||
is_superuser: p.superuser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving identity from request headers. Mirrors the Python
|
||||
/// `Identity | INVALID | None` contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthOutcome {
|
||||
/// A valid token resolved to this identity.
|
||||
Authenticated(Identity),
|
||||
/// No token was offered — the adapter may fall back to session identity.
|
||||
Anonymous,
|
||||
/// A token was present but failed validation — the adapter MUST reject.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Auth config carried by the adapter — JWT and/or MWT secrets. Either may be
|
||||
/// absent; a token type with no configured secret is ignored. Mirrors
|
||||
/// `AuthConfig`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthConfig {
|
||||
pub jwt: Option<JwtConfig>,
|
||||
pub mwt_secret: Option<String>,
|
||||
pub mwt_audience: String,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
jwt: None,
|
||||
mwt_secret: None,
|
||||
mwt_audience: "mizan".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve identity from `X-Mizan-Token` (MWT) then `Authorization: Bearer`
|
||||
/// (JWT). Header lookup is case-sensitive on the names the adapter passes in;
|
||||
/// pass both casings or normalize upstream. Mirrors `authenticate`.
|
||||
pub fn authenticate(
|
||||
mwt_header: Option<&str>,
|
||||
bearer_header: Option<&str>,
|
||||
config: &AuthConfig,
|
||||
now: i64,
|
||||
) -> AuthOutcome {
|
||||
if let (Some(mwt), Some(secret)) = (mwt_header, config.mwt_secret.as_deref()) {
|
||||
if !mwt.is_empty() {
|
||||
return match decode_mwt(mwt, secret, &config.mwt_audience, now) {
|
||||
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||
None => AuthOutcome::Invalid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(bearer), Some(jwt_cfg)) = (bearer_header, config.jwt.as_ref()) {
|
||||
if let Some(token) = bearer.strip_prefix("Bearer ") {
|
||||
return match decode_jwt(token, jwt_cfg, Some("access"), now) {
|
||||
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
|
||||
None => AuthOutcome::Invalid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AuthOutcome::Anonymous
|
||||
}
|
||||
|
||||
/// The `@client(auth=...)` requirement a function declares. `Callable` carries
|
||||
/// the host's own predicate — the adapter resolves it; the core stays free of
|
||||
/// the native request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
None,
|
||||
Required,
|
||||
Staff,
|
||||
Superuser,
|
||||
}
|
||||
|
||||
impl AuthRequirement {
|
||||
/// Parse the IR/`FunctionSpec` auth string into a requirement.
|
||||
/// `"required" | "staff" | "superuser"` → the matching variant; anything
|
||||
/// else (including the absence of an `auth=`) → `None`.
|
||||
pub fn from_str_opt(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("required") | Some("true") => AuthRequirement::Required,
|
||||
Some("staff") => AuthRequirement::Staff,
|
||||
Some("superuser") => AuthRequirement::Superuser,
|
||||
_ => AuthRequirement::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce a function's `auth=` against the resolved identity. `Ok(())` to
|
||||
/// proceed; `Err(MizanError)` (`Unauthorized`/`Forbidden`) to reject. Mirrors
|
||||
/// `authguard.enforce_auth`.
|
||||
pub fn enforce_auth(
|
||||
identity: Option<&Identity>,
|
||||
requirement: &AuthRequirement,
|
||||
) -> Result<(), crate::runtime::MizanError> {
|
||||
use crate::runtime::MizanError;
|
||||
if matches!(requirement, AuthRequirement::None) {
|
||||
return Ok(());
|
||||
}
|
||||
let ident = match identity {
|
||||
Some(i) => i,
|
||||
None => return Err(MizanError::Unauthorized("Authentication required".into())),
|
||||
};
|
||||
match requirement {
|
||||
AuthRequirement::None | AuthRequirement::Required => Ok(()),
|
||||
AuthRequirement::Staff => {
|
||||
if ident.is_staff {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MizanError::Forbidden("Staff access required".into()))
|
||||
}
|
||||
}
|
||||
AuthRequirement::Superuser => {
|
||||
if ident.is_superuser {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MizanError::Forbidden("Superuser access required".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
//! Origin-side cache: HMAC-SHA256 key derivation + a pluggable backend.
|
||||
//!
|
||||
//! Byte-pinned to `cores/mizan-python/src/mizan_core/cache/keys.py`. The HMAC
|
||||
//! message is the JSON-canonical form `{"c":ctx,"p":{sorted params},"r":rev}`
|
||||
//! (with optional `"u":user_id`), emitted with Python's `json.dumps(...,
|
||||
//! sort_keys=True, separators=(",", ":"))` byte layout: keys sorted, no
|
||||
//! whitespace. Every Mizan adapter must produce the identical key for
|
||||
//! identical inputs — `tests/cache_keys_pin.rs` pins this against the Python
|
||||
//! reference and the committed cross-language vectors.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::Value;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Context prefix for broad purge (SCAN pattern), mirroring Python's
|
||||
/// `CONTEXT_KEY_PREFIX`.
|
||||
pub const CONTEXT_KEY_PREFIX: &str = "ctx:";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Normalize a param value to its cross-language-stable string form.
|
||||
///
|
||||
/// Python `str(True)` is `"True"` but JS `String(true)` is `"true"`; the
|
||||
/// reference picks the JSON-native spelling. Numbers and strings stringify
|
||||
/// directly. This must match `keys.py::_normalize` exactly.
|
||||
fn normalize(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Bool(true) => "true".to_string(),
|
||||
Value::Bool(false) => "false".to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
// Arrays/objects have no scalar param meaning; fall back to the JSON
|
||||
// text, matching Python's `str(v)` catch-all for non-scalars.
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-escape a string into `out` byte-for-byte with Python's
|
||||
/// `json.dumps(..., ensure_ascii=True)`: the short escapes for `"`, `\`,
|
||||
/// `\b\f\n\r\t`, `\uXXXX` for the rest of the C0 control range, and — because
|
||||
/// the reference leaves `ensure_ascii` at its default `True` — `\uXXXX` for
|
||||
/// every non-ASCII code point, encoded as a UTF-16 surrogate pair when the
|
||||
/// code point is above the BMP (e.g. `😀` → `😀`).
|
||||
fn push_json_string(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0c}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
|
||||
let mut buf = [0u16; 2];
|
||||
for unit in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
/// Build the exact HMAC message bytes: `{"c":...,"p":{...},"r":...}` with an
|
||||
/// optional `"u":...`. Keys are emitted in sorted order (c, p, r, u) and the
|
||||
/// `p` object's keys are sorted too — equivalent to `sort_keys=True`.
|
||||
fn canonical_message(
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> String {
|
||||
let mut msg = String::new();
|
||||
msg.push('{');
|
||||
|
||||
// "c"
|
||||
msg.push_str("\"c\":");
|
||||
push_json_string(&mut msg, context);
|
||||
|
||||
// "p" — object of normalized, sorted params (BTreeMap iterates sorted).
|
||||
msg.push_str(",\"p\":{");
|
||||
for (i, (k, v)) in params.iter().enumerate() {
|
||||
if i > 0 {
|
||||
msg.push(',');
|
||||
}
|
||||
push_json_string(&mut msg, k);
|
||||
msg.push(':');
|
||||
push_json_string(&mut msg, &normalize(v));
|
||||
}
|
||||
msg.push('}');
|
||||
|
||||
// "r"
|
||||
msg.push_str(",\"r\":");
|
||||
msg.push_str(&rev.to_string());
|
||||
|
||||
// "u" (optional) — sorts after "r".
|
||||
if let Some(uid) = user_id {
|
||||
msg.push_str(",\"u\":");
|
||||
push_json_string(&mut msg, uid);
|
||||
}
|
||||
|
||||
msg.push('}');
|
||||
msg
|
||||
}
|
||||
|
||||
/// Derive a deterministic HMAC-SHA256 cache key.
|
||||
///
|
||||
/// Returns `ctx:{context}:{hmac_hex}` so broad purge can SCAN by the prefix
|
||||
/// `ctx:{context}:*`. Byte-identical to the Python/TS reference for identical
|
||||
/// inputs.
|
||||
pub fn derive_cache_key(
|
||||
secret: &str,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> String {
|
||||
let message = canonical_message(context, params, user_id, rev);
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
||||
mac.update(message.as_bytes());
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("{CONTEXT_KEY_PREFIX}{context}:{hex}")
|
||||
}
|
||||
|
||||
/// Pluggable origin cache store. The HTTP adapter injects a backend (memory
|
||||
/// for tests, Redis in production); dispatch reads/writes through it.
|
||||
pub trait CacheBackend: Send + Sync {
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>>;
|
||||
fn set(&self, key: &str, value: Vec<u8>);
|
||||
fn delete(&self, key: &str);
|
||||
/// Delete every key beginning with `prefix` (broad purge).
|
||||
fn delete_by_prefix(&self, prefix: &str);
|
||||
}
|
||||
|
||||
/// In-memory `CacheBackend` for tests and single-process deployments. Mirrors
|
||||
/// the Python `MemoryCache` — a dict guarded by a lock, no persistence.
|
||||
#[derive(Default)]
|
||||
pub struct MemoryCache {
|
||||
store: std::sync::Mutex<BTreeMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheBackend for MemoryCache {
|
||||
fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.store.lock().unwrap().get(key).cloned()
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: Vec<u8>) {
|
||||
self.store.lock().unwrap().insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) {
|
||||
self.store.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
fn delete_by_prefix(&self, prefix: &str) {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|k, _| !k.starts_with(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin-side cache orchestrator — backend + secret injected by the adapter
|
||||
/// (the config seam). Mirrors Python's `CacheOrchestrator`: disabled (a no-op)
|
||||
/// until both a backend and a secret are present.
|
||||
pub struct CacheOrchestrator {
|
||||
backend: Option<std::sync::Arc<dyn CacheBackend>>,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
impl CacheOrchestrator {
|
||||
pub fn new(backend: Option<std::sync::Arc<dyn CacheBackend>>, secret: Option<String>) -> Self {
|
||||
Self { backend, secret }
|
||||
}
|
||||
|
||||
/// A disabled orchestrator — every op is a no-op. Used by stateless apps.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
backend: None,
|
||||
secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.backend.is_some() && self.secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn key(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> Option<String> {
|
||||
let secret = self.secret.as_deref()?;
|
||||
Some(derive_cache_key(secret, context, params, user_id, rev))
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) -> Option<Vec<u8>> {
|
||||
if !self.enabled() {
|
||||
return None;
|
||||
}
|
||||
let backend = self.backend.as_ref()?;
|
||||
let key = self.key(context, params, user_id, rev)?;
|
||||
backend.get(&key)
|
||||
}
|
||||
|
||||
pub fn put(
|
||||
&self,
|
||||
context: &str,
|
||||
params: &BTreeMap<String, Value>,
|
||||
value: Vec<u8>,
|
||||
user_id: Option<&str>,
|
||||
rev: i64,
|
||||
) {
|
||||
if !self.enabled() {
|
||||
return;
|
||||
}
|
||||
if let (Some(backend), Some(key)) =
|
||||
(self.backend.as_ref(), self.key(context, params, user_id, rev))
|
||||
{
|
||||
backend.set(&key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Purge the cache entries named by an invalidation list. A scoped entry
|
||||
/// (`ScopedContext`) deletes its single derived key; a bare context purges
|
||||
/// by prefix — exactly Python's `CacheOrchestrator.purge`.
|
||||
pub fn purge(&self, invalidate: &[crate::runtime::InvalidationTarget], user_id: Option<&str>) {
|
||||
if !self.enabled() {
|
||||
return;
|
||||
}
|
||||
let backend = match self.backend.as_ref() {
|
||||
Some(b) => b,
|
||||
None => return,
|
||||
};
|
||||
for entry in invalidate {
|
||||
match entry {
|
||||
crate::runtime::InvalidationTarget::Context(ctx)
|
||||
| crate::runtime::InvalidationTarget::Function(ctx) => {
|
||||
backend.delete_by_prefix(&format!("{CONTEXT_KEY_PREFIX}{ctx}:"));
|
||||
}
|
||||
crate::runtime::InvalidationTarget::ScopedContext { context, params } => {
|
||||
let params_tree: BTreeMap<String, Value> =
|
||||
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
if let Some(key) = self.key(context, ¶ms_tree, user_id, 0) {
|
||||
backend.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,6 @@ pub enum TypeShape {
|
||||
Optional(Box<TypeShape>),
|
||||
Enum(Vec<&'static str>),
|
||||
Union(Vec<TypeShape>),
|
||||
/// An `Upload`-typed field — a binary file input. Emits the IR `upload`
|
||||
/// type-child (matching `cores/mizan-python`'s `_emit_upload_node`), with
|
||||
/// optional declarative `max-size` / `content-type` constraints. `None`s
|
||||
/// mean an unconstrained upload.
|
||||
Upload {
|
||||
max_size: Option<i64>,
|
||||
content_types: &'static [&'static str],
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
@@ -160,29 +160,6 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
self.close(indent);
|
||||
}
|
||||
TypeShape::Upload {
|
||||
max_size,
|
||||
content_types,
|
||||
} => {
|
||||
// Match Python's `_emit_upload_node`: `max-size` is the bare
|
||||
// integer (its `repr`); content-types become nested children;
|
||||
// the unconstrained case is a bare `upload` leaf.
|
||||
let mut header: Vec<String> = vec!["upload".into()];
|
||||
if let Some(ms) = max_size {
|
||||
header.push(format!("max-size={ms}"));
|
||||
}
|
||||
let header_refs: Vec<&str> = header.iter().map(String::as_str).collect();
|
||||
if content_types.is_empty() {
|
||||
self.leaf(indent, &header_refs);
|
||||
} else {
|
||||
self.open(indent, &header_refs);
|
||||
for ct in content_types.iter() {
|
||||
let lit = kdl_string(ct);
|
||||
self.leaf(indent + 1, &["content-type", &lit]);
|
||||
}
|
||||
self.close(indent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +464,7 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||
walk_shape_refs(b, visit);
|
||||
}
|
||||
}
|
||||
TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {}
|
||||
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,43 +14,25 @@
|
||||
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
|
||||
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod graph_check;
|
||||
pub mod ir;
|
||||
pub mod kdl;
|
||||
pub mod manifest;
|
||||
pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod shapes;
|
||||
pub mod ssr;
|
||||
pub mod traits;
|
||||
pub mod upload;
|
||||
|
||||
pub use auth::{
|
||||
authenticate, compute_permission_key, create_access_token, create_mwt, create_refresh_token,
|
||||
decode_jwt, decode_mwt, enforce_auth, now_unix, AuthConfig, AuthOutcome, AuthRequirement,
|
||||
Identity, JwtConfig, JwtPayload, MwtPayload,
|
||||
};
|
||||
pub use upload::Upload;
|
||||
pub use cache::{
|
||||
derive_cache_key, CacheBackend, CacheOrchestrator, MemoryCache, CONTEXT_KEY_PREFIX,
|
||||
};
|
||||
pub use ir::{
|
||||
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
|
||||
};
|
||||
pub use kdl::{build_ir, snake_to_camel};
|
||||
pub use manifest::{generate_edge_manifest, generate_edge_manifest_json};
|
||||
pub use registry::{
|
||||
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
|
||||
FUNCTIONS, TYPES,
|
||||
};
|
||||
pub use runtime::{
|
||||
compute_invalidation, compute_merges, format_invalidate_header, InvalidationTarget,
|
||||
MergeEntry, MizanError, RequestHandle,
|
||||
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
|
||||
RequestHandle,
|
||||
};
|
||||
pub use shapes::{QueryProjection, ShapeField};
|
||||
pub use ssr::{SsrBridge, SsrError, WorkerCommand};
|
||||
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
|
||||
|
||||
// Re-export proc macros so consumers depend on one crate.
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Edge manifest — the static JSON that Mizan Edge reads to configure CDN
|
||||
//! cache rules + invalidation routing.
|
||||
//!
|
||||
//! Mirrors `backends/mizan-django/src/mizan/export/__init__.py`'s
|
||||
//! `generate_edge_manifest`: a `{version, contexts, mutations}` document where
|
||||
//! each context carries its functions, endpoints, params, `user_scoped`, and
|
||||
//! `render_strategy` (the PSR axis), and each mutation carries its `affects`
|
||||
//! and `auto_scoped_params`. Keys are emitted alphabetically (the Django
|
||||
//! command serializes with `sort_keys=True`); `to_json_string` matches that.
|
||||
|
||||
use crate::registry::{context_members, CONTEXTS, FUNCTIONS};
|
||||
use crate::traits::FunctionSpec;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Params that imply a user-scoped context → `render_strategy:
|
||||
/// "dynamic_cached"`. Anything else renders as `"psr"`. Matches Python's
|
||||
/// `_USER_SCOPED_PARAMS`.
|
||||
const USER_SCOPED_PARAMS: [&str; 4] = ["user_id", "user", "owner_id", "account_id"];
|
||||
|
||||
/// Build the edge manifest as a `serde_json::Value`. `base_url` is the Mizan
|
||||
/// mount point (default `/api/mizan`).
|
||||
pub fn generate_edge_manifest(base_url: &str) -> Value {
|
||||
let mut contexts = Map::new();
|
||||
|
||||
// Contexts, alphabetical by name (BTreeSet over the registered names).
|
||||
let ctx_names: BTreeSet<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
|
||||
for ctx_name in &ctx_names {
|
||||
let members = context_members(ctx_name);
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut param_names: BTreeSet<&'static str> = BTreeSet::new();
|
||||
let mut functions_meta: Vec<Value> = Vec::new();
|
||||
let mut page_routes: Vec<String> = Vec::new();
|
||||
|
||||
for fn_spec in &members {
|
||||
for p in fn_spec.input_params() {
|
||||
param_names.insert(p.name);
|
||||
}
|
||||
// The Rust IR has no view-path/route metadata yet; every function
|
||||
// is an RPC path. (`route`/`view_path` land with the view-path
|
||||
// macro extension.)
|
||||
functions_meta.push(json!({ "name": fn_spec.name(), "path": "rpc" }));
|
||||
}
|
||||
|
||||
let user_scoped = param_names
|
||||
.iter()
|
||||
.any(|p| USER_SCOPED_PARAMS.contains(p));
|
||||
|
||||
let mut ctx_entry = Map::new();
|
||||
ctx_entry.insert("functions".into(), Value::Array(functions_meta));
|
||||
ctx_entry.insert(
|
||||
"endpoints".into(),
|
||||
json!([format!("{base_url}/ctx/{ctx_name}/")]),
|
||||
);
|
||||
ctx_entry.insert(
|
||||
"params".into(),
|
||||
Value::Array(
|
||||
param_names
|
||||
.iter()
|
||||
.map(|p| Value::String((*p).to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
ctx_entry.insert("user_scoped".into(), Value::Bool(user_scoped));
|
||||
ctx_entry.insert(
|
||||
"render_strategy".into(),
|
||||
Value::String(
|
||||
if user_scoped {
|
||||
"dynamic_cached"
|
||||
} else {
|
||||
"psr"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
if !page_routes.is_empty() {
|
||||
page_routes.sort();
|
||||
ctx_entry.insert(
|
||||
"page_routes".into(),
|
||||
Value::Array(page_routes.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
contexts.insert((*ctx_name).to_string(), Value::Object(ctx_entry));
|
||||
}
|
||||
|
||||
// Mutations — every non-private function declaring `affects`, alphabetical.
|
||||
let mut fns: Vec<&'static dyn FunctionSpec> = FUNCTIONS.iter().copied().collect();
|
||||
fns.sort_by_key(|f| f.name());
|
||||
|
||||
let mut mutations = Map::new();
|
||||
for fn_spec in &fns {
|
||||
let affected: BTreeSet<&'static str> = fn_spec
|
||||
.affects()
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
crate::ir::AffectTarget::Context(name) => Some(*name),
|
||||
crate::ir::AffectTarget::Function { context, .. } => *context,
|
||||
})
|
||||
.collect();
|
||||
if affected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mutation = Map::new();
|
||||
mutation.insert(
|
||||
"affects".into(),
|
||||
Value::Array(
|
||||
affected
|
||||
.iter()
|
||||
.map(|c| Value::String((*c).to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
// Auto-scoped params: this mutation's params that also name a param of
|
||||
// an affected context.
|
||||
let fn_params: BTreeSet<&'static str> =
|
||||
fn_spec.input_params().iter().map(|p| p.name).collect();
|
||||
let mut auto_scoped: BTreeSet<&'static str> = BTreeSet::new();
|
||||
for ctx in &affected {
|
||||
let mut ctx_params: BTreeSet<&'static str> = BTreeSet::new();
|
||||
for m in context_members(ctx) {
|
||||
for p in m.input_params() {
|
||||
ctx_params.insert(p.name);
|
||||
}
|
||||
}
|
||||
for p in fn_params.intersection(&ctx_params) {
|
||||
auto_scoped.insert(*p);
|
||||
}
|
||||
}
|
||||
if !auto_scoped.is_empty() {
|
||||
mutation.insert(
|
||||
"auto_scoped_params".into(),
|
||||
Value::Array(
|
||||
auto_scoped
|
||||
.iter()
|
||||
.map(|p| Value::String((*p).to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if fn_spec.private() {
|
||||
mutation.insert("private".into(), Value::Bool(true));
|
||||
}
|
||||
|
||||
mutations.insert(fn_spec.name().to_string(), Value::Object(mutation));
|
||||
}
|
||||
|
||||
json!({
|
||||
"version": 1,
|
||||
"contexts": Value::Object(contexts),
|
||||
"mutations": Value::Object(mutations),
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON-serialize the manifest with sorted keys (the Django command uses
|
||||
/// `json.dumps(..., sort_keys=True)`); `indent` of 0 → compact.
|
||||
pub fn generate_edge_manifest_json(base_url: &str, indent: usize) -> String {
|
||||
let value = generate_edge_manifest(base_url);
|
||||
let sorted = sort_value(&value);
|
||||
if indent == 0 {
|
||||
serde_json::to_string(&sorted).unwrap()
|
||||
} else {
|
||||
serde_json::to_string_pretty(&sorted).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively re-key every object so serialization is sorted-key, matching
|
||||
/// Python's `sort_keys=True`. (serde_json::Map preserves insertion order, so
|
||||
/// we rebuild via BTreeMap ordering.)
|
||||
fn sort_value(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
let mut keys: Vec<&String> = m.keys().collect();
|
||||
keys.sort();
|
||||
let mut out = Map::new();
|
||||
for k in keys {
|
||||
out.insert(k.clone(), sort_value(&m[k]));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
@@ -135,75 +135,6 @@ impl InvalidationTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode for the `X-Mizan-Invalidate` header, matching Python's
|
||||
/// `urllib.parse.quote(str(v), safe='')`: the RFC 3986 unreserved set
|
||||
/// (`A-Za-z0-9_.-~`) passes through; every other byte (of the UTF-8 encoding)
|
||||
/// becomes `%XX` with **upper-case** hex.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render an invalidation value to a JSON-ish string for header param values.
|
||||
/// Mirrors Python's `str(v)`: a JSON string yields its raw text; numbers and
|
||||
/// booleans their literal spelling (`true`/`false`); other shapes their JSON.
|
||||
fn header_value_str(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Null => "None".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a list of targets to the `X-Mizan-Invalidate` header value —
|
||||
/// byte-for-byte with `cores/mizan-python`'s `format_invalidate_header`:
|
||||
/// comma-separated contexts, semicolon-separated URL-encoded params per
|
||||
/// context (params sorted by key).
|
||||
///
|
||||
/// `[Context("user")]` → `user`
|
||||
/// `[Context("user"), Context("notifications")]` → `user, notifications`
|
||||
/// `[ScopedContext{user, {user_id:5}}]` → `user;user_id=5`
|
||||
/// `[ScopedContext{search, {q:"hello world"}}]` → `search;q=hello%20world`
|
||||
pub fn format_invalidate_header(targets: &[InvalidationTarget]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for t in targets {
|
||||
match t {
|
||||
InvalidationTarget::Context(name) | InvalidationTarget::Function(name) => {
|
||||
parts.push(name.clone());
|
||||
}
|
||||
InvalidationTarget::ScopedContext { context, params } => {
|
||||
if params.is_empty() {
|
||||
parts.push(context.clone());
|
||||
} else {
|
||||
// BTreeMap-sort the keys to match Python's `sorted(params.items())`.
|
||||
let mut keys: Vec<&String> = params.keys().collect();
|
||||
keys.sort();
|
||||
let param_str = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let v = ¶ms[*k];
|
||||
format!("{}={}", url_encode(k), url_encode(&header_value_str(v)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
parts.push(format!("{context};{param_str}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// One entry in the response's `merge` array. Server-resolved slot — the
|
||||
/// kernel writes the value into `bundle[slot]` directly.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! Shapes — typed query projection over the registered type graph.
|
||||
//!
|
||||
//! The AFI-common capability is "given the typed shape a function returns,
|
||||
//! derive the field projection a query layer should select" — the same role
|
||||
//! django-readers plays on Django (a `Shape` declares fields + nested shapes,
|
||||
//! and `_spec` is the projection handed to the ORM). The binding is per-ORM;
|
||||
//! the *capability* — deriving the projection from the declared shape — is
|
||||
//! shared, so it lives here in the core and each adapter rides it.
|
||||
//!
|
||||
//! A `QueryProjection` is computed from a registered named type's struct
|
||||
//! shape: scalar fields become leaf selections, `Ref`-to-struct fields become
|
||||
//! nested projections (recursively), lists/optionals unwrap to their element.
|
||||
//! It is the typed, ORM-agnostic answer to "what columns/relations does this
|
||||
//! response need?" — the dead-field-elimination the whole-stack story wants,
|
||||
//! reached from the response type.
|
||||
|
||||
use crate::ir::{NamedType, TypeShape};
|
||||
use crate::registry::TYPES;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One selected field of a projection: a scalar leaf, or a nested projection
|
||||
/// for a related struct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShapeField {
|
||||
/// A scalar/primitive column.
|
||||
Leaf(String),
|
||||
/// A related struct, with its own projection.
|
||||
Nested(String, QueryProjection),
|
||||
}
|
||||
|
||||
impl ShapeField {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
ShapeField::Leaf(n) | ShapeField::Nested(n, _) => n,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, ORM-agnostic field projection derived from a named struct type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct QueryProjection {
|
||||
/// The named type this projects (the struct's IR name).
|
||||
pub type_name: String,
|
||||
pub fields: Vec<ShapeField>,
|
||||
}
|
||||
|
||||
impl QueryProjection {
|
||||
/// The flat list of scalar leaf field names selected at this level.
|
||||
pub fn leaf_names(&self) -> Vec<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ShapeField::Leaf(n) => Some(n.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The nested relations selected at this level, name → sub-projection.
|
||||
pub fn nested(&self) -> Vec<(&str, &QueryProjection)> {
|
||||
self.fields
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ShapeField::Nested(n, p) => Some((n.as_str(), p)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the registry's named-type table once (name → shape).
|
||||
fn type_table() -> BTreeMap<&'static str, NamedType> {
|
||||
let mut t = BTreeMap::new();
|
||||
for entry in TYPES {
|
||||
t.insert(entry.name, (entry.shape_fn)());
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Unwrap a `TypeShape` to the named struct it ultimately references, if any
|
||||
/// — peeling `List`/`Optional`. Returns the referenced type name.
|
||||
fn referenced_struct<'a>(
|
||||
shape: &TypeShape,
|
||||
table: &'a BTreeMap<&'static str, NamedType>,
|
||||
) -> Option<&'a str> {
|
||||
match shape {
|
||||
TypeShape::Ref(name) => {
|
||||
// Only treat it as nested if it resolves to a struct.
|
||||
match table.get(name) {
|
||||
Some(NamedType::Struct(_)) => Some(name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
TypeShape::List(inner) | TypeShape::Optional(inner) => referenced_struct(inner, table),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the projection for a registered named type by its IR name. `None`
|
||||
/// if the name is absent or is not a struct.
|
||||
pub fn project(type_name: &str) -> Option<QueryProjection> {
|
||||
let table = type_table();
|
||||
project_inner(type_name, &table, &mut Vec::new())
|
||||
}
|
||||
|
||||
fn project_inner(
|
||||
type_name: &str,
|
||||
table: &BTreeMap<&'static str, NamedType>,
|
||||
stack: &mut Vec<String>,
|
||||
) -> Option<QueryProjection> {
|
||||
let body = table.get(type_name)?;
|
||||
let fields = match body {
|
||||
NamedType::Struct(fields) => fields,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Guard against recursive types (self-referential shapes): a name already
|
||||
// on the stack projects to its scalar leaves only, no further descent.
|
||||
let recursing = stack.iter().any(|n| n == type_name);
|
||||
stack.push(type_name.to_string());
|
||||
|
||||
let mut out = Vec::new();
|
||||
for field in fields {
|
||||
if !recursing {
|
||||
if let Some(nested_name) = referenced_struct(&field.shape, table) {
|
||||
if let Some(sub) = project_inner(nested_name, table, stack) {
|
||||
out.push(ShapeField::Nested(field.name.to_string(), sub));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(ShapeField::Leaf(field.name.to_string()));
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
Some(QueryProjection {
|
||||
type_name: type_name.to_string(),
|
||||
fields: out,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive the projection for a function's output type, by function name.
|
||||
pub fn project_function_output(fn_name: &str) -> Option<QueryProjection> {
|
||||
let fn_spec = crate::registry::lookup_function(fn_name)?;
|
||||
project(fn_spec.output_type())
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! SSR bridge — drive a persistent Bun subprocess for React `renderToString`.
|
||||
//!
|
||||
//! Same wire protocol as the Python `SSRBridge`
|
||||
//! (`backends/mizan-django/src/mizan/ssr/bridge.py`): newline-delimited
|
||||
//! JSON-RPC over the worker's stdin/stdout.
|
||||
//!
|
||||
//! → {"id": 1, "method": "render", "params": {"file": "/abs/X.tsx", "props": {...}}}
|
||||
//! ← {"id": 1, "html": "<div>...</div>"}
|
||||
//!
|
||||
//! The worker emits `{"id": 0, "ready": true}` once on startup; `render`
|
||||
//! blocks until that arrives. A background reader thread demultiplexes
|
||||
//! responses by `id` and parks each caller on a per-request condvar. The
|
||||
//! subprocess stays alive across requests and is respawned on the next render
|
||||
//! if it has died. `command` is injected so a test can drive the exact same
|
||||
//! framing/correlation path against a stub worker without Bun installed.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How the bridge launches its worker. The default is `bun run <worker>`; a
|
||||
/// test injects a stub program that speaks the same JSON-RPC framing.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerCommand {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkerCommand {
|
||||
/// The production launcher: `bun run <worker_path>`.
|
||||
pub fn bun(worker_path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
program: "bun".to_string(),
|
||||
args: vec!["run".to_string(), worker_path.into()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SsrError {
|
||||
Spawn(String),
|
||||
Timeout(String),
|
||||
Render(String),
|
||||
Pipe(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SsrError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SsrError::Spawn(m) => write!(f, "SSR worker spawn failed: {m}"),
|
||||
SsrError::Timeout(m) => write!(f, "SSR render timed out: {m}"),
|
||||
SsrError::Render(m) => write!(f, "SSR render failed: {m}"),
|
||||
SsrError::Pipe(m) => write!(f, "SSR worker pipe broken: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrError {}
|
||||
|
||||
/// Shared slot a parked caller waits on. The reader thread fills `result` and
|
||||
/// flips `done`, then notifies.
|
||||
#[derive(Default)]
|
||||
struct Slot {
|
||||
done: Mutex<Option<Value>>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
child: Option<Child>,
|
||||
stdin: Option<ChildStdin>,
|
||||
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
|
||||
ready: Arc<(Mutex<bool>, Condvar)>,
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
/// A persistent Bun SSR subprocess, thread-safe across concurrent `render`s.
|
||||
pub struct SsrBridge {
|
||||
command: WorkerCommand,
|
||||
timeout: Duration,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl SsrBridge {
|
||||
pub fn new(command: WorkerCommand, timeout: Duration) -> Self {
|
||||
Self {
|
||||
command,
|
||||
timeout,
|
||||
inner: Mutex::new(Inner {
|
||||
child: None,
|
||||
stdin: None,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
ready: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
counter: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Production constructor: `bun run <worker>` with a 5s render timeout.
|
||||
pub fn bun(worker_path: impl Into<String>) -> Self {
|
||||
Self::new(WorkerCommand::bun(worker_path), Duration::from_secs(5))
|
||||
}
|
||||
|
||||
fn ensure_running(&self, inner: &mut Inner) -> Result<(), SsrError> {
|
||||
if let Some(child) = inner.child.as_mut() {
|
||||
if matches!(child.try_wait(), Ok(None)) {
|
||||
return Ok(()); // still alive
|
||||
}
|
||||
}
|
||||
|
||||
*inner.ready.0.lock().unwrap() = false;
|
||||
inner.pending.lock().unwrap().clear();
|
||||
|
||||
let mut child = Command::new(&self.command.program)
|
||||
.args(&self.command.args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| SsrError::Spawn(e.to_string()))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| SsrError::Spawn("no stdout".into()))?;
|
||||
inner.stdin = Some(
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| SsrError::Spawn("no stdin".into()))?,
|
||||
);
|
||||
inner.child = Some(child);
|
||||
|
||||
let pending = inner.pending.clone();
|
||||
let ready = inner.ready.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("mizan-ssr-reader".to_string())
|
||||
.spawn(move || Self::read_loop(stdout, pending, ready))
|
||||
.map_err(|e| SsrError::Spawn(e.to_string()))?;
|
||||
|
||||
// Block until the worker signals readiness.
|
||||
let (lock, cv) = &*inner.ready;
|
||||
let mut is_ready = lock.lock().unwrap();
|
||||
while !*is_ready {
|
||||
let (g, timed_out) = cv.wait_timeout(is_ready, self.timeout).unwrap();
|
||||
is_ready = g;
|
||||
if timed_out.timed_out() && !*is_ready {
|
||||
return Err(SsrError::Timeout("worker failed to start".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_loop(
|
||||
stdout: std::process::ChildStdout,
|
||||
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
|
||||
ready: Arc<(Mutex<bool>, Condvar)>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let msg: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // malformed line; skip, matching Python
|
||||
};
|
||||
let id = msg.get("id").and_then(|v| v.as_u64());
|
||||
|
||||
// Ready signal: {"id": 0, "ready": true}.
|
||||
if id == Some(0) && msg.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||
let (lock, cv) = &*ready;
|
||||
*lock.lock().unwrap() = true;
|
||||
cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(id) = id {
|
||||
let slot = pending.lock().unwrap().remove(&id);
|
||||
if let Some(slot) = slot {
|
||||
*slot.done.lock().unwrap() = Some(msg);
|
||||
slot.cv.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `file` (an absolute `.tsx`/`.jsx` path) with `props`, returning
|
||||
/// the HTML string. Spawns the worker on first use; respawns if it died.
|
||||
pub fn render(&self, file: &str, props: Value) -> Result<String, SsrError> {
|
||||
let (id, stdin_taken, slot) = {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
self.ensure_running(&mut inner)?;
|
||||
inner.counter += 1;
|
||||
let id = inner.counter;
|
||||
let slot = Arc::new(Slot::default());
|
||||
inner.pending.lock().unwrap().insert(id, slot.clone());
|
||||
|
||||
let request = json!({
|
||||
"id": id,
|
||||
"method": "render",
|
||||
"params": {"file": file, "props": props},
|
||||
});
|
||||
let mut line = serde_json::to_string(&request).unwrap();
|
||||
line.push('\n');
|
||||
|
||||
let write_res = inner
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or_else(|| SsrError::Pipe("no stdin".into()))
|
||||
.and_then(|w| {
|
||||
w.write_all(line.as_bytes())
|
||||
.and_then(|_| w.flush())
|
||||
.map_err(|e| SsrError::Pipe(e.to_string()))
|
||||
});
|
||||
(id, write_res, slot)
|
||||
};
|
||||
|
||||
if let Err(e) = stdin_taken {
|
||||
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Park on the slot until the reader fills it or we time out.
|
||||
let mut done = slot.done.lock().unwrap();
|
||||
while done.is_none() {
|
||||
let (g, timed_out) = slot.cv.wait_timeout(done, self.timeout).unwrap();
|
||||
done = g;
|
||||
if timed_out.timed_out() && done.is_none() {
|
||||
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
|
||||
return Err(SsrError::Timeout(format!("render of {file:?}")));
|
||||
}
|
||||
}
|
||||
let msg = done.take().unwrap();
|
||||
drop(done);
|
||||
|
||||
if let Some(err) = msg.get("error").and_then(|e| e.as_str()) {
|
||||
return Err(SsrError::Render(err.to_string()));
|
||||
}
|
||||
match msg.get("html").and_then(|h| h.as_str()) {
|
||||
Some(html) => Ok(html.to_string()),
|
||||
None => Err(SsrError::Render("response missing `html`".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the subprocess. Idempotent; called from `Drop`.
|
||||
pub fn shutdown(&self) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.stdin = None; // close stdin → worker sees EOF
|
||||
if let Some(mut child) = inner.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SsrBridge {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,6 @@ pub trait FunctionSpec: Send + Sync {
|
||||
fn private(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The `@client(auth=...)` requirement, as the IR string form: `None`
|
||||
/// (no guard), `"required"`, `"staff"`, or `"superuser"`. The dispatch
|
||||
/// core resolves this into an `AuthRequirement` and rejects accordingly.
|
||||
fn auth(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
fn is_form(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//! Upload — first-class binary input for `#[mizan::client]` functions.
|
||||
//!
|
||||
//! Rust analog of `cores/mizan-python/src/mizan_core/upload.py`. An adapter
|
||||
//! parses a multipart file part and binds it into the function's typed input
|
||||
//! as the JSON shape `Upload` deserializes:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"filename": "a.png", "content_type": "image/png", "data_b64": "...", "size": 12}
|
||||
//! ```
|
||||
//!
|
||||
//! Declaring an `Upload`-typed parameter makes a function multipart-aware end
|
||||
//! to end (the generated client switches the call to `multipart/form-data`;
|
||||
//! each adapter binds the part). `Upload` is `Deserialize`, so it drops into a
|
||||
//! `#[mizan(...)]` input struct like any other field and the dispatch
|
||||
//! wrapper's `serde_json::from_value` validates it.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use serde::de::{self, Deserializer};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A bound, decoded upload handed to a `#[mizan::client]` function. The bytes
|
||||
/// are eagerly decoded from the adapter's base64 transport form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Upload {
|
||||
pub filename: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Upload {
|
||||
pub fn size(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Persist the upload to `path`.
|
||||
pub fn save(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||
std::fs::write(path, &self.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire form an adapter encodes a file part into. Kept separate from
|
||||
/// `Upload` so the public handle exposes decoded bytes, not base64.
|
||||
#[derive(Deserialize)]
|
||||
struct UploadWire {
|
||||
#[serde(default)]
|
||||
filename: Option<String>,
|
||||
#[serde(default)]
|
||||
content_type: Option<String>,
|
||||
data_b64: String,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Upload {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = UploadWire::deserialize(deserializer)?;
|
||||
let data = STANDARD
|
||||
.decode(wire.data_b64.as_bytes())
|
||||
.map_err(|e| de::Error::custom(format!("invalid base64 upload data: {e}")))?;
|
||||
Ok(Upload {
|
||||
filename: wire.filename,
|
||||
content_type: wire.content_type,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Cross-language pin: Rust `derive_cache_key` must be byte-identical to the
|
||||
//! Python reference (`cores/mizan-python/.../cache/keys.py`) and to the
|
||||
//! committed cross-language vectors that `tests/afi` and `mizan-ts` also pin.
|
||||
//!
|
||||
//! The Python reference is the oracle: a subprocess mints the key with fixed
|
||||
//! inputs and the Rust output must match exactly. `never if backend == X` —
|
||||
//! one spec, pinned both ways.
|
||||
|
||||
use mizan_core::derive_cache_key;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// The `tests/afi` dir, whose venv has `mizan_core` + PyJWT installed.
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
/// Run the Python reference via `uv run python -c <code>` in tests/afi and
|
||||
/// return its single stdout line, trimmed.
|
||||
fn py(code: &str) -> String {
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn tree(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_vectors_match() {
|
||||
// The exact pins committed in cores/mizan-python/tests/test_keys.py and
|
||||
// backends/mizan-ts/tests — the canonical cross-language anchor.
|
||||
let secret = "test-pin-secret-that-is-32bytes!";
|
||||
|
||||
let public = derive_cache_key(secret, "user", &tree(&[("user_id", json!("5"))]), None, 0);
|
||||
assert_eq!(
|
||||
public,
|
||||
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6"
|
||||
);
|
||||
|
||||
let scoped = derive_cache_key(
|
||||
secret,
|
||||
"user",
|
||||
&tree(&[("user_id", json!("5"))]),
|
||||
Some("5"),
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
scoped,
|
||||
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_python_reference_across_inputs() {
|
||||
// A spread of shapes: multi-param (order-independence), numeric vs string,
|
||||
// bool/null normalization, user-scoped, nonzero rev.
|
||||
let cases: Vec<(&str, BTreeMap<String, Value>, Option<&str>, i64)> = vec![
|
||||
("user", tree(&[("user_id", json!("5"))]), None, 0),
|
||||
("user", tree(&[("user_id", json!("5"))]), Some("5"), 0),
|
||||
("user", tree(&[("user_id", json!("5"))]), Some("5"), 3),
|
||||
(
|
||||
"search",
|
||||
tree(&[("q", json!("hello world")), ("page", json!(2))]),
|
||||
None,
|
||||
0,
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
tree(&[("on", json!(true)), ("off", json!(false)), ("nil", json!(null))]),
|
||||
Some("42"),
|
||||
1,
|
||||
),
|
||||
("empty", tree(&[]), None, 0),
|
||||
(
|
||||
"unicode",
|
||||
tree(&[("name", json!("café—ñ"))]),
|
||||
None,
|
||||
0,
|
||||
),
|
||||
];
|
||||
|
||||
for (ctx, params, uid, rev) in cases {
|
||||
let rust = derive_cache_key("pin-secret-xyz", ctx, ¶ms, uid, rev);
|
||||
|
||||
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
|
||||
let params_json = serde_json::to_string(
|
||||
¶ms.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<serde_json::Map<_, _>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let uid_arg = match uid {
|
||||
Some(u) => format!("'{u}'"),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
let code = format!(
|
||||
"import json; from mizan_core.cache.keys import derive_cache_key; \
|
||||
print(derive_cache_key('pin-secret-xyz', {ctx:?}, json.loads(r'''{params_json}'''), {uid_arg}, {rev}))",
|
||||
);
|
||||
let expected = py(&code);
|
||||
assert_eq!(
|
||||
rust, expected,
|
||||
"cache-key mismatch for ctx={ctx} params={params_json} uid={uid:?} rev={rev}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Cross-language pin: Rust `format_invalidate_header` must be byte-identical
|
||||
//! to `cores/mizan-python/.../invalidation.py::format_invalidate_header`.
|
||||
//!
|
||||
//! The `X-Mizan-Invalidate` header is co-equal with the JSON body channel in
|
||||
//! the spec; Edge parses it to purge. The Python reference is the oracle.
|
||||
|
||||
use mizan_core::{format_invalidate_header, InvalidationTarget};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
fn py_header(json_list: &str) -> String {
|
||||
let code = format!(
|
||||
"import json; from mizan_core.invalidation import format_invalidate_header; \
|
||||
print(format_invalidate_header(json.loads(r'''{json_list}''')))",
|
||||
);
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", &code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Trim the trailing newline only — the header value itself may be empty.
|
||||
let s = String::from_utf8(out.stdout).unwrap();
|
||||
s.strip_suffix('\n').unwrap_or(&s).to_string()
|
||||
}
|
||||
|
||||
fn scoped(ctx: &str, params: &[(&str, serde_json::Value)]) -> InvalidationTarget {
|
||||
InvalidationTarget::ScopedContext {
|
||||
context: ctx.to_string(),
|
||||
params: params.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_python_reference() {
|
||||
let cases: Vec<(Vec<InvalidationTarget>, &str)> = vec![
|
||||
(vec![InvalidationTarget::Context("user".into())], r#"["user"]"#),
|
||||
(
|
||||
vec![
|
||||
InvalidationTarget::Context("user".into()),
|
||||
InvalidationTarget::Context("notifications".into()),
|
||||
],
|
||||
r#"["user", "notifications"]"#,
|
||||
),
|
||||
(
|
||||
vec![scoped("user", &[("user_id", json!(5))])],
|
||||
r#"[{"context": "user", "params": {"user_id": 5}}]"#,
|
||||
),
|
||||
(
|
||||
vec![scoped("search", &[("q", json!("hello world"))])],
|
||||
r#"[{"context": "search", "params": {"q": "hello world"}}]"#,
|
||||
),
|
||||
(
|
||||
// Multiple params → sorted by key, semicolon-joined.
|
||||
vec![scoped("u", &[("b", json!("2")), ("a", json!("1"))])],
|
||||
r#"[{"context": "u", "params": {"b": "2", "a": "1"}}]"#,
|
||||
),
|
||||
(
|
||||
// Special chars that must percent-encode: &, =, /, space, unicode.
|
||||
vec![scoped("c", &[("k", json!("a&b=c/d e—ñ"))])],
|
||||
r#"[{"context": "c", "params": {"k": "a&b=c/d e—ñ"}}]"#,
|
||||
),
|
||||
(
|
||||
// Mixed bare + scoped.
|
||||
vec![
|
||||
scoped("user", &[("user_id", json!(5))]),
|
||||
InvalidationTarget::Context("notifications".into()),
|
||||
],
|
||||
r#"[{"context": "user", "params": {"user_id": 5}}, "notifications"]"#,
|
||||
),
|
||||
];
|
||||
|
||||
for (targets, json_list) in cases {
|
||||
let rust = format_invalidate_header(&targets);
|
||||
let expected = py_header(json_list);
|
||||
assert_eq!(rust, expected, "header mismatch for {json_list}");
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Behavior tests for the Shapes projection + edge-manifest derivation,
|
||||
//! driven off a small registered fixture (same graph the AFI fixture uses:
|
||||
//! a nested struct, a user context with a shared `user_id` param, and an
|
||||
//! `affects` mutation).
|
||||
|
||||
use mizan_core as mizan;
|
||||
use mizan_core::prelude::*;
|
||||
use mizan_core::{generate_edge_manifest, shapes, RequestHandle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Address {
|
||||
pub city: String,
|
||||
pub zip: String,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Person {
|
||||
pub user_id: i64,
|
||||
pub name: String,
|
||||
pub address: Address,
|
||||
}
|
||||
|
||||
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Ok {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[mizan::context("people")]
|
||||
pub struct PeopleCtx;
|
||||
|
||||
#[mizan::client(context = PeopleCtx)]
|
||||
pub async fn person(_req: &RequestHandle<'_>, user_id: i64) -> Person {
|
||||
Person {
|
||||
user_id,
|
||||
name: "x".into(),
|
||||
address: Address {
|
||||
city: "c".into(),
|
||||
zip: "z".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[mizan::client(affects = PeopleCtx)]
|
||||
pub async fn rename_person(_req: &RequestHandle<'_>, user_id: i64, _name: String) -> Ok {
|
||||
let _ = user_id;
|
||||
Ok { ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_projection_descends_nested_structs() {
|
||||
let proj = shapes::project_function_output("person").expect("projects");
|
||||
assert_eq!(proj.type_name, "personOutput");
|
||||
// Scalar leaves at the top level.
|
||||
let leaves = proj.leaf_names();
|
||||
assert!(leaves.contains(&"user_id"));
|
||||
assert!(leaves.contains(&"name"));
|
||||
// `address` is a nested struct → a sub-projection, not a leaf.
|
||||
assert!(!leaves.contains(&"address"));
|
||||
let nested = proj.nested();
|
||||
assert_eq!(nested.len(), 1);
|
||||
let (name, sub) = nested[0];
|
||||
assert_eq!(name, "address");
|
||||
let sub_leaves = sub.leaf_names();
|
||||
assert!(sub_leaves.contains(&"city") && sub_leaves.contains(&"zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_manifest_has_context_render_strategy_and_mutation() {
|
||||
let m = generate_edge_manifest("/api/mizan");
|
||||
|
||||
// Context: user-scoped (has `user_id`) → render_strategy dynamic_cached.
|
||||
let people = &m["contexts"]["people"];
|
||||
assert_eq!(people["user_scoped"], serde_json::json!(true));
|
||||
assert_eq!(people["render_strategy"], serde_json::json!("dynamic_cached"));
|
||||
assert_eq!(
|
||||
people["endpoints"],
|
||||
serde_json::json!(["/api/mizan/ctx/people/"])
|
||||
);
|
||||
assert_eq!(people["params"], serde_json::json!(["user_id"]));
|
||||
|
||||
// Mutation: rename_person affects people, auto-scopes user_id.
|
||||
let mutation = &m["mutations"]["rename_person"];
|
||||
assert_eq!(mutation["affects"], serde_json::json!(["people"]));
|
||||
assert_eq!(
|
||||
mutation["auto_scoped_params"],
|
||||
serde_json::json!(["user_id"])
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Behavior test for the SSR bridge's framing + request/response correlation.
|
||||
//!
|
||||
//! Bun isn't required (it isn't installed in CI): a stub worker speaking the
|
||||
//! exact same newline-delimited JSON-RPC protocol stands in. The stub emits
|
||||
//! the `{"id":0,"ready":true}` handshake, then for each `render` request
|
||||
//! echoes back `{"id":N,"html":"<rendered:FILE props=PROPS>"}` — exercising
|
||||
//! the ready-gate, the per-request id correlation, and the html extraction
|
||||
//! that the real Bun worker drives.
|
||||
|
||||
use mizan_core::{SsrBridge, WorkerCommand};
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A tiny Python stub that speaks the SSR worker protocol. Written to a temp
|
||||
/// file and launched via `python3 <file>`.
|
||||
const STUB: &str = r#"
|
||||
import sys, json
|
||||
# Handshake: announce readiness exactly as the Bun worker does.
|
||||
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\n")
|
||||
sys.stdout.flush()
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
msg = json.loads(line)
|
||||
mid = msg.get("id")
|
||||
if msg.get("method") == "render":
|
||||
p = msg["params"]
|
||||
# A sentinel file name forces the worker-error branch.
|
||||
if p["file"] == "/boom.tsx":
|
||||
sys.stdout.write(json.dumps({"id": mid, "error": "render exploded"}) + "\n")
|
||||
else:
|
||||
html = "<rendered:%s props=%s>" % (p["file"], json.dumps(p["props"], sort_keys=True))
|
||||
sys.stdout.write(json.dumps({"id": mid, "html": html}) + "\n")
|
||||
else:
|
||||
sys.stdout.write(json.dumps({"id": mid, "error": "unknown method"}) + "\n")
|
||||
sys.stdout.flush()
|
||||
"#;
|
||||
|
||||
fn write_stub() -> std::path::PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("mizan_ssr_stub_{}.py", std::process::id()));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(STUB.as_bytes()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_drives_worker_protocol() {
|
||||
let stub = write_stub();
|
||||
let bridge = SsrBridge::new(
|
||||
WorkerCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![stub.to_string_lossy().to_string()],
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
// First render — spawns the worker, waits for the ready handshake.
|
||||
let html = bridge
|
||||
.render("/abs/Hello.tsx", json!({"name": "World"}))
|
||||
.expect("first render succeeds");
|
||||
assert_eq!(
|
||||
html,
|
||||
r#"<rendered:/abs/Hello.tsx props={"name": "World"}>"#
|
||||
);
|
||||
|
||||
// Second render reuses the same subprocess; id correlation must keep the
|
||||
// responses matched to their requests.
|
||||
let html2 = bridge
|
||||
.render("/abs/Other.tsx", json!({"a": 1, "b": 2}))
|
||||
.expect("second render succeeds");
|
||||
assert_eq!(
|
||||
html2,
|
||||
r#"<rendered:/abs/Other.tsx props={"a": 1, "b": 2}>"#
|
||||
);
|
||||
|
||||
bridge.shutdown();
|
||||
let _ = std::fs::remove_file(&stub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_propagates_worker_error() {
|
||||
let stub = write_stub();
|
||||
let bridge = SsrBridge::new(
|
||||
WorkerCommand {
|
||||
program: "python3".to_string(),
|
||||
args: vec![stub.to_string_lossy().to_string()],
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
// The sentinel file makes the stub return an `error` frame; the bridge
|
||||
// must surface it as `SsrError::Render`, not a successful empty render.
|
||||
let err = bridge
|
||||
.render("/boom.tsx", json!({}))
|
||||
.expect_err("worker error propagates");
|
||||
assert!(matches!(err, mizan_core::SsrError::Render(_)));
|
||||
assert!(err.to_string().contains("render exploded"));
|
||||
|
||||
// A subsequent good render on the same worker still succeeds.
|
||||
assert!(bridge.render("/ok.tsx", json!({})).is_ok());
|
||||
bridge.shutdown();
|
||||
let _ = std::fs::remove_file(&stub);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
//! Cross-language pin: Rust HS256 JWT + MWT must be byte-identical to the
|
||||
//! Python core (`auth/jwt.py`, `mwt.py`, both PyJWT-backed).
|
||||
//!
|
||||
//! Byte-identity is the whole point — Edge and the origin cache key on these
|
||||
//! tokens, so a one-byte divergence is a cache-key spoof surface. The Python
|
||||
//! reference is the oracle: it mints with fixed claims + a fixed `iat`/`exp`
|
||||
//! (we pin `now` on both sides) and the Rust token must match exactly. We also
|
||||
//! prove round-trip: Rust decodes a Python-minted token and vice-versa.
|
||||
|
||||
use mizan_core::{
|
||||
create_access_token, create_mwt, create_refresh_token, decode_jwt, decode_mwt,
|
||||
compute_permission_key, JwtConfig,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn afi_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tests/afi")
|
||||
.canonicalize()
|
||||
.expect("tests/afi exists")
|
||||
}
|
||||
|
||||
fn py(code: &str) -> String {
|
||||
let out = Command::new("uv")
|
||||
.args(["run", "python", "-c", code])
|
||||
.current_dir(afi_dir())
|
||||
.output()
|
||||
.expect("invoke uv run python");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"python reference failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_700_000_000;
|
||||
|
||||
#[test]
|
||||
fn jwt_access_token_matches_python() {
|
||||
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||
let rust = create_access_token(&cfg, "42", "sess-abc", true, false, NOW);
|
||||
|
||||
// Python: freeze time to NOW, mint an access token with the same claims.
|
||||
let code = format!(
|
||||
"import time; from unittest import mock; \
|
||||
from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||
orig = time.time; \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_access_token('42', 'sess-abc', cfg, is_staff=True, is_superuser=False)); \
|
||||
time.time = orig",
|
||||
);
|
||||
let expected = py(&code);
|
||||
assert_eq!(rust, expected, "JWT access-token byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_refresh_token_matches_python() {
|
||||
let cfg = JwtConfig::new("jwt-pin-secret");
|
||||
let rust = create_refresh_token(&cfg, "7", "sid-9", false, true, NOW);
|
||||
let code = format!(
|
||||
"import time; from mizan_core.auth.jwt import JWTConfig, create_refresh_token; \
|
||||
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_refresh_token('7', 'sid-9', cfg, is_staff=False, is_superuser=True))",
|
||||
);
|
||||
assert_eq!(rust, py(&code), "JWT refresh-token byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_roundtrip_decode_python_minted() {
|
||||
// A Python-minted access token must decode in Rust with matching claims.
|
||||
let code = format!(
|
||||
"import time; from mizan_core.auth.jwt import JWTConfig, create_access_token; \
|
||||
cfg = JWTConfig(private_key='rt-secret', public_key='rt-secret'); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_access_token('99', 'sess-x', cfg, is_staff=False, is_superuser=True))",
|
||||
);
|
||||
let token = py(&code);
|
||||
let cfg = JwtConfig::new("rt-secret");
|
||||
let payload = decode_jwt(&token, &cfg, Some("access"), NOW + 10).expect("decodes");
|
||||
assert_eq!(payload.sub, "99");
|
||||
assert_eq!(payload.sid, "sess-x");
|
||||
assert!(payload.superuser);
|
||||
assert!(!payload.staff);
|
||||
// Wrong secret → None; expired → None.
|
||||
assert!(decode_jwt(&token, &JwtConfig::new("nope"), None, NOW + 10).is_none());
|
||||
assert!(decode_jwt(&token, &cfg, Some("access"), NOW + 10_000).is_none());
|
||||
// Type mismatch → None.
|
||||
assert!(decode_jwt(&token, &cfg, Some("refresh"), NOW + 10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_key_matches_python() {
|
||||
let perms = vec!["app.add_thing".to_string(), "app.view_thing".to_string()];
|
||||
let rust = compute_permission_key(true, false, &perms);
|
||||
let code =
|
||||
"from mizan_core.mwt import compute_permission_key; \
|
||||
from unittest.mock import MagicMock; \
|
||||
u = MagicMock(); u.is_staff=True; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value={'app.view_thing','app.add_thing'}); \
|
||||
print(compute_permission_key(u))";
|
||||
assert_eq!(rust, py(code), "pkey byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mwt_matches_python() {
|
||||
// Build the same pkey on both sides, then mint with frozen time + fixed
|
||||
// kid/audience and compare bytes.
|
||||
let perms = vec!["app.view_thing".to_string()];
|
||||
let pkey = compute_permission_key(false, false, &perms);
|
||||
let rust = create_mwt("mwt-pin-secret", "5", false, false, &pkey, 300, "mizan", "v1", NOW);
|
||||
|
||||
let code = format!(
|
||||
"import time; from unittest.mock import MagicMock; \
|
||||
from mizan_core.mwt import create_mwt; \
|
||||
u = MagicMock(); u.pk=5; u.is_staff=False; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value={{'app.view_thing'}}); \
|
||||
time.time = lambda: {NOW}; \
|
||||
print(create_mwt(u, 'mwt-pin-secret', ttl=300, audience='mizan', kid='v1'))",
|
||||
);
|
||||
assert_eq!(rust, py(&code), "MWT byte mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mwt_roundtrip_and_rejections() {
|
||||
let pkey = compute_permission_key(true, true, &[]);
|
||||
let token = create_mwt("rt-mwt", "13", true, true, &pkey, 300, "mizan", "v1", NOW);
|
||||
let p = decode_mwt(&token, "rt-mwt", "mizan", NOW + 5).expect("decodes");
|
||||
assert_eq!(p.sub, "13");
|
||||
assert!(p.staff && p.superuser);
|
||||
assert_eq!(p.kid, "v1");
|
||||
assert_eq!(p.pkey.len(), 64);
|
||||
// Wrong secret, wrong audience, expired → None.
|
||||
assert!(decode_mwt(&token, "wrong", "mizan", NOW + 5).is_none());
|
||||
assert!(decode_mwt(&token, "rt-mwt", "other", NOW + 5).is_none());
|
||||
assert!(decode_mwt(&token, "rt-mwt", "mizan", NOW + 10_000).is_none());
|
||||
|
||||
// And a Python-minted MWT decodes in Rust.
|
||||
let code = format!(
|
||||
"import time; from unittest.mock import MagicMock; from mizan_core.mwt import create_mwt; \
|
||||
u = MagicMock(); u.pk=21; u.is_staff=True; u.is_superuser=False; \
|
||||
u.get_all_permissions = MagicMock(return_value=set()); \
|
||||
time.time = lambda: {NOW}; print(create_mwt(u, 'rt-mwt', ttl=300, audience='mizan', kid='v1'))",
|
||||
);
|
||||
let py_token = py(&code);
|
||||
let pp = decode_mwt(&py_token, "rt-mwt", "mizan", NOW + 5).expect("py mwt decodes in rust");
|
||||
assert_eq!(pp.sub, "21");
|
||||
assert!(pp.staff && !pp.superuser);
|
||||
}
|
||||
Reference in New Issue
Block a user