AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -60,32 +60,32 @@ Every capability below is **AFI-common**: each adapter owes a binding, and a ❌
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | | — | ✅ |
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | | — | ✅ |
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | |
| File uploads (`Upload` type) | ✅ | ✅ | | | |
| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | |
| File uploads (`Upload` type) | ✅ | ✅ | | | |
### Edge, cache & enforcement
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:|
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | | | ✅ |
| Origin-side HMAC cache | ✅ | ✅ | | | ✅ |
| Edge manifest export | ✅ | | | — | ✅ |
| PSR (`render_strategy` in manifest) | ✅ | | | — | ✅ |
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | |
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | | | ✅ |
| Origin-side HMAC cache | ✅ | ✅ | | | ✅ |
| Edge manifest export | ✅ | | | — | ✅ |
| PSR (`render_strategy` in manifest) | ✅ | | | — | ✅ |
| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | |
### Extension points
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:|
| WebSocket transport (`websocket=` declared) | ✅ | | | | |
| SSR bridge (subprocess renderer) | ✅ | | | | |
| JWT auth (access / refresh) | ✅ | ✅ | | | |
| MWT (edge identity token) | ✅ | ✅ | | — | |
| Typed query projection (Shapes) | ✅ | | | | |
| Forms (schema / validate / submit) | ✅ | | | | |
| WebSocket transport (`websocket=` declared) | ✅ | | | | |
| SSR bridge (subprocess renderer) | ✅ | | | | |
| JWT auth (access / refresh) | ✅ | ✅ | | | |
| MWT (edge identity token) | ✅ | ✅ | | — | |
| Typed query projection (Shapes) | ✅ | | | | |
| Forms (schema / validate / submit) | ✅ | | | | |
**Notes**

View File

@@ -1,10 +1,11 @@
"""
Mizan Edge Manifest Generator.
Mizan Edge Manifest Generator (Django adapter surface).
Generates the Edge manifest — a static JSON mapping contexts to URL
patterns and params, consumed by Mizan Edge at deploy time for CDN
cache invalidation. Independent from the Mizan IR; the IR drives
codegen, the manifest drives CDN purging.
The manifest derivation is AFI-common and lives in `mizan_core.manifest`;
Django exposes it through `python manage.py export_edge_manifest` and this
re-export. The manifest maps contexts to URL patterns and params, consumed by
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
Usage:
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
@@ -12,145 +13,10 @@ Usage:
from __future__ import annotations
import json
import re
from typing import Any
from mizan_core.registry import get_context_groups, get_registry
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
__all__ = [
"generate_edge_manifest",
"generate_edge_manifest_json",
]
def generate_edge_manifest(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
) -> dict[str, Any]:
"""
Generate the Edge manifest — a static JSON mapping contexts to URL
patterns and params for CDN cache purging.
The manifest is consumed by Mizan Edge at deploy time. When Edge
receives X-Mizan-Invalidate: user;user_id=5, it:
1. Looks up 'user' in the manifest
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
3. Purges the resolved URLs + the context API endpoint
Args:
base_url: The Mizan API mount point (default: /api/mizan)
view_urls: Optional mapping of context names to URL patterns for
view-path functions. These are URLs that Edge should
also purge when a context is invalidated.
Returns:
Manifest dict suitable for JSON serialization.
"""
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
groups = get_context_groups()
registry = get_registry()
all_functions = registry.get("functions", {})
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
for ctx_name, fn_names in sorted(groups.items()):
param_names: set[str] = set()
functions_meta: list[dict[str, Any]] = []
page_routes: list[str] = []
for fn_name in fn_names:
fn_cls = all_functions.get(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
for param_name in input_cls.model_fields:
param_names.add(param_name)
meta = getattr(fn_cls, "_meta", {})
route = meta.get("route")
view_path = meta.get("view_path")
fn_entry: dict[str, Any] = {
"name": fn_name,
"path": "view" if view_path else "rpc",
}
if route:
fn_entry["route"] = route
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(route)
if meta.get("rev"):
fn_entry["rev"] = meta["rev"]
if meta.get("cache") is not None and meta.get("cache") is not True:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
ctx_entry: dict[str, Any] = {
"functions": functions_meta,
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
"params": sorted_params,
"user_scoped": user_scoped,
"render_strategy": "dynamic_cached" if user_scoped else "psr",
}
if page_routes:
ctx_entry["page_routes"] = page_routes
if view_urls and ctx_name in view_urls:
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
manifest["contexts"][ctx_name] = ctx_entry
for fn_name, fn_cls in sorted(all_functions.items()):
meta = getattr(fn_cls, "_meta", {})
if not meta.get("affects"):
continue
affected_contexts = list({a["name"] for a in meta["affects"]})
mutation: dict[str, Any] = {"affects": affected_contexts}
# Auto-scoped params — function params that match context params
input_cls = getattr(fn_cls, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
fn_params = set(input_cls.model_fields.keys())
auto_scoped: list[str] = []
for ctx_name in affected_contexts:
ctx_param_names: set[str] = set()
ctx_fns = groups.get(ctx_name, [])
for ctx_fn_name in ctx_fns:
ctx_fn_cls = all_functions.get(ctx_fn_name)
if ctx_fn_cls is None:
continue
ctx_input = getattr(ctx_fn_cls, "Input", None)
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
ctx_param_names.update(ctx_input.model_fields.keys())
for p in fn_params:
if p in ctx_param_names and p not in auto_scoped:
auto_scoped.append(p)
if auto_scoped:
mutation["auto_scoped_params"] = sorted(auto_scoped)
if meta.get("private"):
mutation["private"] = True
if meta.get("route"):
mutation["route"] = meta["route"]
mutation["methods"] = meta.get("methods", ["POST"])
manifest["mutations"][fn_name] = mutation
return manifest
def generate_edge_manifest_json(
base_url: str = "/api/mizan",
view_urls: dict[str, list[str]] | None = None,
indent: int = 2,
) -> str:
"""JSON-serialize the Edge manifest."""
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)

View File

@@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist
from django.template.backends.base import BaseEngine
from django.utils.safestring import mark_safe
from .bridge import SSRBridge
from mizan_core.ssr import SSRBridge
class MizanTemplate:

View File

@@ -9,8 +9,12 @@ dependencies = [
"fastapi>=0.110",
"pydantic>=2.0",
"python-multipart>=0.0.9",
"sqlalchemy>=2.0",
]
[project.scripts]
mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0",

View File

@@ -5,14 +5,20 @@ HTTP RPC dispatch and context bundling on top of mizan-core's function
registry, sharing the auth / invalidation / cache / upload core with the
Django adapter.
WebSocket, Forms, Shapes, and the SSR bridge are AFI-common capabilities
this adapter does not wire yet — open gaps on the capability-parity board
(`tests/afi/`), not out-of-scope. "Use FastAPI's native WebSocket / an ORM
of choice" is the non-goal: the AFI exists precisely to wire those to the
generated typed client through one decorator, so an adapter that defers to
the native primitive isn't yet a complete AFI adapter. The SSR bridge in
particular is framework-agnostic (`mizan.ssr.bridge.SSRBridge` has no Django
coupling) and is mountable here directly.
The full AFI-common surface is wired here over FastAPI-native primitives,
each riding the shared core:
- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)`
functions through the same `mizan_core.dispatch` as `POST /call/`.
- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared
`mizan_core.ssr.SSRBridge` Bun subprocess.
- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest`
console entry) emit the manifest derived in `mizan_core.manifest`, including
each context's `render_strategy`.
- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to
SQLAlchemy (same declaration surface as the Django `django-readers` binding).
- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit
role functions over Pydantic.
Usage:
from fastapi import FastAPI
@@ -42,11 +48,30 @@ from .executor import (
compute_invalidation,
execute_function,
)
# Register the FastAPI/Starlette response base so view-path detection works in
# mizan_core.client.function (a @client function returning a Response is a
# view-path function — header-only invalidation, "view" in the edge manifest).
# Must run before any @client-decorated code is evaluated.
from starlette.responses import Response as _Response
from mizan_core.client.function import set_framework_response_base as _set_response_base
_set_response_base(_Response)
from . import shapes, forms
from .router import router, mizan_exception_handler, mizan_validation_handler
from .auth import MizanAuthMiddleware, mizan_auth
from .config import MizanConfig, from_env
from .manifest import edge_manifest, generate_edge_manifest, render_strategies
from .ssr import SSRRenderer
from mizan_core.upload import File, Upload, UploadedFile
# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit)
# are submodule bindings; expose their public primitives at the package root.
Shape = shapes.Shape
Diff = shapes.Diff
NestedDiff = shapes.NestedDiff
mizanForm = forms.mizanForm
FormConfig = forms.FormConfig
__all__ = [
"Upload",
"File",
@@ -60,6 +85,17 @@ __all__ = [
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"edge_manifest",
"generate_edge_manifest",
"render_strategies",
"SSRRenderer",
"shapes",
"forms",
"Shape",
"Diff",
"NestedDiff",
"mizanForm",
"FormConfig",
"ErrorCode",
"MizanError",
"NotFound",

View File

@@ -0,0 +1,245 @@
"""
Forms — the Pydantic binding (schema / validate / submit roles).
A Mizan form is exposed as three server functions — `{name}.schema`,
`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of
`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and
identical to the Django adapter's (`mizan.forms`); only the *binding* differs:
Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`.
from mizan_fastapi.forms import mizanForm, FormConfig
class ContactForm(mizanForm):
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
name: str
email: EmailStr
message: str
def on_submit_success(self, request) -> dict:
send_email(self.model_dump())
return {"sent": True}
Subclassing registers the three role functions automatically (parity with the
Django `mizanFormMixin.__init_subclass__` auto-registration):
contact.schema → field definitions (FormSchema)
contact.validate → structured field errors (FormValidation)
contact.submit → validate, then on_submit_success / on_submit_failure
"""
from __future__ import annotations
from typing import Any, ClassVar, get_args, get_origin
from pydantic import BaseModel, ValidationError, create_model
from mizan_core.client.function import ServerFunction
from mizan_core.registry import get_all_functions, register
from .schemas import (
FieldError,
FieldErrorList,
FieldSchema,
FormMeta,
FormSchema,
FormSubmitFail,
FormSubmitPass,
FormValidation,
)
__all__ = [
"FormConfig",
"mizanForm",
"get_forms",
"FormSchema",
"FormValidation",
"FormSubmitPass",
"FormSubmitFail",
]
# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the
# Django binding's `_django_field_to_python_type` intent: hand the client a real
# field type instead of a generic string.
_TYPE_WIDGET = {
bool: ("checkbox", "CheckboxInput"),
int: ("number", "NumberInput"),
float: ("number", "NumberInput"),
str: ("text", "TextInput"),
}
class FormConfig(BaseModel):
"""Form metadata + frontend behavior (parity with `mizanFormMeta`)."""
name: str
title: str | None = None
subtitle: str | None = None
submit_label: str = "Submit"
live_validation: bool = True
live_form_errors: bool = False
refetch_schema_on_validate: bool = False
def _unwrap_optional(annotation: Any) -> Any:
"""`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged."""
if get_origin(annotation) in (None,):
return annotation
args = [a for a in get_args(annotation) if a is not type(None)]
if len(args) == 1 and type(None) in get_args(annotation):
return args[0]
return annotation
def _field_type_widget(annotation: Any) -> tuple[str, str]:
base = _unwrap_optional(annotation)
return _TYPE_WIDGET.get(base, ("text", "TextInput"))
def _humanize(name: str) -> str:
return name.replace("_", " ").title()
def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema:
"""Derive a `FormSchema` from a Pydantic form's fields + config."""
cfg = form_cls.mizan
fields: list[FieldSchema] = []
for field_name, info in form_cls.model_fields.items():
type_str, widget = _field_type_widget(info.annotation)
required = info.is_required()
initial = None if required else info.get_default(call_default_factory=False)
if initial is None and info.default is not None and info.default is not ...:
initial = info.default
meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
fields.append(
FieldSchema(
name=field_name,
label=str(info.title or _humanize(field_name)),
type=type_str,
widget=widget,
required=required,
disabled=bool(meta.get("disabled", False)),
help_text=str(info.description or ""),
initial=initial if initial is not ... else None,
max_length=getattr(info, "max_length", None),
min_length=getattr(info, "min_length", None),
choices=None,
)
)
return FormSchema(
name=cfg.name,
title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")),
subtitle=cfg.subtitle,
submit_label=cfg.submit_label,
fields=fields,
meta=FormMeta(
refetch_schema_on_validate=cfg.refetch_schema_on_validate,
live_validation=cfg.live_validation,
live_form_errors=cfg.live_form_errors,
),
)
def _validation_from_error(exc: ValidationError) -> FormValidation:
"""Group a Pydantic `ValidationError` into the `FormValidation` wire shape."""
by_field: dict[str, list[FieldError]] = {}
for err in exc.errors():
loc = err.get("loc", ())
field = str(loc[0]) if loc else "__all__"
by_field.setdefault(field, []).append(
FieldError(message=err.get("msg", "Invalid value"), code=err.get("type"))
)
return FormValidation(
errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()]
)
def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]:
"""Validate `data`; return `(instance|None, validation)` — instance None on failure."""
try:
instance = form_cls(**(data or {}))
return instance, FormValidation(errors=[])
except ValidationError as exc:
return None, _validation_from_error(exc)
class mizanForm(BaseModel):
"""Base for a Pydantic-backed Mizan form.
Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing
auto-registers the schema/validate/submit role functions. Override
`on_submit_success` / `on_submit_failure` for submit-time behavior.
"""
mizan: ClassVar[FormConfig]
def on_submit_success(self, request: Any) -> dict | None:
"""Handle a validated submission. Override; returns optional result data."""
return None
def on_submit_failure(self, request: Any, errors: FormValidation) -> None:
"""Handle a failed submission (logging, etc.). Override."""
return None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cfg = cls.__dict__.get("mizan")
if isinstance(cfg, FormConfig):
_register_form(cls)
def _register_form(form_cls: type[mizanForm]) -> None:
"""Register `{name}.schema/.validate/.submit` for a Pydantic form class."""
cfg = form_cls.mizan
name = cfg.name
pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_"))
schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {}))
validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...))
submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...))
class SchemaFunction(ServerFunction):
Input = schema_input
Output = FormSchema
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"}
def call(self, input) -> FormSchema:
return build_form_schema(form_cls)
class ValidateFunction(ServerFunction):
Input = validate_input
Output = FormValidation
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"}
def call(self, input) -> FormValidation:
_, validation = _validate(form_cls, input.data)
return validation
class SubmitFunction(ServerFunction):
Input = submit_input
Output = FormSubmitPass
_meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"}
def call(self, input) -> FormSubmitPass | FormSubmitFail:
instance, validation = _validate(form_cls, input.data)
if instance is not None:
return FormSubmitPass(success=True, data=instance.on_submit_success(self.request))
instance_for_failure = form_cls.model_construct(**(input.data or {}))
instance_for_failure.on_submit_failure(self.request, validation)
return FormSubmitFail(success=False, errors=validation)
for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")):
fn.__name__ = f"{name}_{role}"
fn.__qualname__ = fn.__name__
register(fn, f"{name}.{role}")
def get_forms() -> dict[str, list]:
"""Group registered form role functions by form name (parity helper)."""
forms: dict[str, list] = {}
for _, cls in get_all_functions().items():
meta = getattr(cls, "_meta", {})
if meta.get("form"):
forms.setdefault(meta.get("form_name"), []).append(cls)
return forms

View File

@@ -0,0 +1,77 @@
"""
Form role output schemas — the wire shapes the schema/validate/submit roles emit.
These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta,
FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated
client is identical regardless of which backend authored the form. The only
difference is the source: Django builds these from `forms.Field` introspection;
this builds them from Pydantic `FieldInfo`.
"""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel
class FormMeta(BaseModel):
"""Frontend behavior flags (parity with the Django adapter)."""
refetch_schema_on_validate: bool = False
live_validation: bool = True
live_form_errors: bool = False
class FieldChoice(BaseModel):
value: str
label: str
class FieldError(BaseModel):
message: str
code: Optional[str] = None
class FieldErrorList(BaseModel):
field: str
errors: list[FieldError]
class FieldSchema(BaseModel):
name: str
label: str
type: str
widget: str
required: bool
disabled: bool
help_text: str
initial: Any = None
max_length: Optional[int] = None
min_length: Optional[int] = None
choices: Optional[list[FieldChoice]] = None
class FormSchema(BaseModel):
"""Schema returned by the `.schema` role: form metadata + field definitions."""
name: str
title: str
subtitle: Optional[str] = None
submit_label: str
fields: list[FieldSchema]
meta: FormMeta = FormMeta()
class FormValidation(BaseModel):
errors: list[FieldErrorList]
class FormSubmitPass(BaseModel):
success: bool
data: Optional[dict] = None
class FormSubmitFail(BaseModel):
success: bool
errors: FormValidation

View File

@@ -0,0 +1,98 @@
"""
Edge manifest — FastAPI adapter surface.
The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`);
this module exposes it over FastAPI's surface as a callable and a console entry
(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest`
management command.
The `render_strategy` field each context carries — `"psr"` when the context has
no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge
reads to decide between one shared pre-rendered artifact and a per-user cached
one. It is derived in the core from the same registry metadata, so FastAPI and
Django emit byte-identical manifests for an identical registry.
CLI:
mizan-fastapi-edge-manifest myproject.app
mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json
The positional argument is an import target (``module`` or ``module:attr``); it
is imported for its registration side effects (importing the module runs the
`@client` decorators and `register(...)` calls that populate the registry)
before the manifest is derived.
"""
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
from typing import Any
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"]
def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]:
"""The Edge manifest for the current registry.
Call after the app's `@client` functions are imported/registered. The
returned dict carries each context's ``render_strategy`` (PSR vs.
dynamic_cached) and the mutation→context invalidation routing.
"""
return generate_edge_manifest(base_url=base_url)
def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]:
"""Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``.
PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a
context with no user-scoped param renders one shared artifact (``psr``) that
is re-rendered on mutation; a user-scoped context renders per-user
(``dynamic_cached``). This surfaces that decision directly so a PSR driver can
enumerate which contexts to pre-render without re-deriving it.
"""
contexts = edge_manifest(base_url)["contexts"]
return {name: entry["render_strategy"] for name, entry in contexts.items()}
def _import_target(target: str) -> None:
"""Import a ``module`` or ``module:attr`` target for its registration effects."""
module_name = target.split(":", 1)[0]
importlib.import_module(module_name)
def main(argv: list[str] | None = None) -> int:
"""Console entry: import the app target, emit the Edge manifest as JSON."""
parser = argparse.ArgumentParser(
prog="mizan-fastapi-edge-manifest",
description="Export the Mizan Edge manifest for a FastAPI app.",
)
parser.add_argument(
"app",
help="Import target whose @client functions to register "
"(e.g. 'myproject.app' or 'myproject.app:app').",
)
parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.")
parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.")
parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).")
args = parser.parse_args(argv)
sys.path.insert(0, "")
_import_target(args.app)
indent = args.indent if args.indent > 0 else None
text = generate_edge_manifest_json(base_url=args.base_url, indent=indent)
if args.output:
Path(args.output).write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Request
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, ValidationError
@@ -25,7 +25,7 @@ from starlette.datastructures import UploadFile
from mizan_core.auth import INVALID, authenticate
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized
from mizan_core.registry import get_function
from mizan_core.upload import UploadedFile, bind_uploads
@@ -150,6 +150,108 @@ async def context_fetch(context_name: str, request: Request) -> Response:
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
# ─── WebSocket RPC transport ──────────────────────────────────────────────────
def _ws_identity(websocket: WebSocket, cfg: MizanConfig):
"""Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a
token decode from the handshake headers. A present-but-invalid token rejects.
Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces
identically over either transport.
"""
existing = getattr(getattr(websocket, "state", None), "user", None)
if existing is not None:
return existing
ident = authenticate(websocket.headers, cfg.auth)
if ident is INVALID:
raise Unauthorized("Invalid or expired token")
return ident
def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]:
err: dict[str, Any] = {"code": exc.code.value, "message": exc.message}
if exc.details:
err["details"] = exc.details
return {"id": request_id, "ok": False, "error": err}
@router.websocket("/ws/")
async def websocket_rpc(websocket: WebSocket) -> None:
"""WebSocket RPC transport for `@client(websocket=True)` functions.
Frame protocol (parity with mizan-django's Channels consumer):
{"action": "rpc", "id": "<req>", "fn": "<name>", "args": {...}}
{"id": "<req>", "ok": true, "data": <result>, "invalidate": [...], "merge"?: [...]}
{"id": "<req>", "ok": false, "error": {"code", "message", "details"?}}
Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as
`POST /call/`, so input validation, `auth=` enforcement, invalidation, merge,
and origin-cache purge are identical across transports. Only functions that
declared `websocket=True` are callable here; an HTTP-only function returns a
`FORBIDDEN` frame rather than executing.
"""
cfg = get_config(websocket)
await websocket.accept()
try:
identity = _ws_identity(websocket, cfg)
except Unauthorized as exc:
await websocket.send_json(_error_frame(None, exc))
await websocket.close(code=1008)
return
try:
while True:
content = await websocket.receive_json()
await _handle_ws_rpc(websocket, content, identity, cfg)
except WebSocketDisconnect:
return
async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None:
"""Dispatch one WS RPC frame through the shared dispatch core."""
if content.get("action") != "rpc":
await websocket.send_json({"error": f"Unknown action: {content.get('action')}"})
return
request_id = content.get("id")
fn_name = content.get("fn")
args = content.get("args", {})
if not fn_name:
await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field")))
return
fn_class = get_function(fn_name)
if fn_class is None:
await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found")))
return
if not getattr(fn_class, "_meta", {}).get("websocket"):
await websocket.send_json(
_error_frame(
request_id,
Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."),
)
)
return
try:
res = await dispatch_call(
DispatchRequest(identity=identity, args=args, native_request=websocket),
fn_name, cfg.cache,
)
except MizanError as exc:
await websocket.send_json(_error_frame(request_id, exc))
return
frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data,
"invalidate": res.invalidate or []}
if res.merge:
frame["merge"] = res.merge
await websocket.send_json(frame)
# ─── Exception handler ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,307 @@
"""
Typed query projection (Shapes) — the SQLAlchemy binding.
A Shape is a Pydantic model that declares *which* fields and relationships of an
ORM model to project. The declaration surface is identical to the Django
adapter's `mizan.shapes` (`django-readers` binding):
class AuthorShape(Shape[Author]):
id: int
name: str
books: list[BookShape] = [] # nested relationship
AuthorShape.query(session, lambda s: s.where(Author.name == "Ann"))
Only the ORM binding differs: where the Django Shape lowers its spec to
`django-readers` pairs (queryset prepare + instance project), this lowers it to a
SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested
relationship (the projection-load that keeps the query count flat), then projects
each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a
constructed shape against current DB rows, mirroring the Django semantics.
The one surface difference SQLAlchemy forces is an explicit `session` argument to
`query` / `diff` / `diff_many` — Django models carry an implicit `objects`
manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the
Shape declaration.
"""
from __future__ import annotations
import types
from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session, selectinload
_M = TypeVar("_M")
_S = TypeVar("_S", bound="Shape")
def _extract_shape_class(hint) -> type[Shape] | None:
"""The nested Shape a field annotation projects, if any.
Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional —
the same forms the Django binding's `_extract_shape_class` accepts.
"""
origin = getattr(hint, "__origin__", None)
args = getattr(hint, "__args__", ())
if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape):
return args[0]
if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape:
return hint
if origin is Union or isinstance(hint, types.UnionType):
for arg in args:
if arg is type(None):
continue
if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape:
return arg
return None
def _resolve_model(cls) -> Any | None:
"""The mapped model a Shape subclass is parameterized on (`Shape[Model]`)."""
for base in cls.__bases__:
meta = getattr(base, "__pydantic_generic_metadata__", None) or {}
if meta.get("origin") is Shape and (args := meta.get("args")):
return args[0]
return None
class Shape(BaseModel, Generic[_M]):
"""Typed projection over a SQLAlchemy mapped model.
Subclass as `Shape[Model]`; annotate the fields/relationships to project.
Scalar annotations become columns to read; annotations referencing another
Shape become relationships to eager-load and project recursively.
"""
_model: ClassVar[Any]
_nested: ClassVar[dict[str, type[Shape]]]
_field_names: ClassVar[list[str]]
_pk_field: ClassVar[str]
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not (model := _resolve_model(cls)):
return
mapper = sa_inspect(model)
cls._model = model
cls._nested = {}
pk_cols = mapper.primary_key
cls._pk_field = pk_cols[0].key if pk_cols else "id"
hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__
field_names: list[str] = []
for name, hint in hints.items():
if name.startswith("_"):
continue
if shape_cls := _extract_shape_class(hint):
cls._nested[name] = shape_cls
else:
field_names.append(name)
cls._field_names = field_names
# ─── Loading + projection ────────────────────────────────────────────────
@classmethod
def _loader_options(cls) -> list[Any]:
"""`selectinload(...)` chains for every nested relationship (recursive).
This is the SQLAlchemy analogue of django-readers' prefetch wiring: each
nested Shape contributes a `selectinload` on its relationship attribute,
with the child Shape's own loader options nested beneath it, so the whole
projection loads in O(depth) queries rather than N+1.
"""
options: list[Any] = []
for name, shape_cls in cls._nested.items():
attr = getattr(cls._model, name)
child = shape_cls._loader_options()
loader = selectinload(attr)
options.append(loader.options(*child) if child else loader)
return options
@classmethod
def _project(cls: type[_S], instance: Any) -> _S:
"""Project a loaded ORM instance into this Shape (recursively for nested)."""
data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names}
for name, shape_cls in cls._nested.items():
related = getattr(instance, name)
if related is None:
data[name] = None
elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)):
data[name] = [shape_cls._project(child) for child in related]
else:
data[name] = shape_cls._project(related)
return cls.model_validate(data)
@classmethod
def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]:
"""Project the model into a list of shapes.
Args:
session: An open SQLAlchemy `Session`.
*stmt_fns: Callables `(select) -> select` applied in order to the base
`select(Model)` — filters/ordering/limits (the SQLAlchemy analogue
of the Django binding's queryset functions).
**relation_stmt: Per-relationship callables `(select) -> select` whose
criteria scope a nested relationship's load (e.g.
``books=lambda s: s.where(Book.is_published.is_(True))``).
Returns:
A list of projected shape instances.
"""
stmt = select(cls._model)
loaders = cls._loader_options_scoped(relation_stmt)
if loaders:
stmt = stmt.options(*loaders)
for fn in stmt_fns:
stmt = fn(stmt)
rows = session.execute(stmt).unique().scalars().all()
return [cls._project(obj) for obj in rows]
@classmethod
def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]:
"""`_loader_options`, but with caller-supplied criteria applied per relation."""
if not relation_stmt:
return cls._loader_options()
options: list[Any] = []
for name, shape_cls in cls._nested.items():
attr = getattr(cls._model, name)
loader = selectinload(attr)
child = shape_cls._loader_options()
if child:
loader = loader.options(*child)
scope = relation_stmt.get(name)
if scope is not None:
# `selectinload(...).and_(...)` filters the related rows loaded.
criteria = scope(select(shape_cls._model)).whereclause
if criteria is not None:
loader = selectinload(attr.and_(criteria))
if child:
loader = loader.options(*child)
options.append(loader)
return options
@classmethod
def _get_pk(cls, instance) -> Any | None:
return getattr(instance, cls._pk_field, None)
# ─── Diff ────────────────────────────────────────────────────────────────
@classmethod
def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]:
"""Diff a batch of shapes against current DB state in one fetch.
New items (no PK) diff against `None`; existing items batch-fetch by PK.
Raises if an item declares a PK that no row matches.
"""
pk_field = cls._pk_field
pk_map: dict[Any, _S] = {}
new_items: list[_S] = []
for item in items:
pk = cls._get_pk(item)
(pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item))
current_map: dict[Any, _S] = {}
if pk_map:
pk_col = getattr(cls._model, pk_field)
current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys()))))
current_map = {cls._get_pk(c): c for c in current}
results: list[tuple[_S, Diff]] = []
for item in new_items:
results.append((item, cls._diff_one(item, None)))
for pk, item in pk_map.items():
current = current_map.get(pk)
if current is None:
raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist")
results.append((item, cls._diff_one(item, current)))
return results
@classmethod
def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff":
pk_field = cls._pk_field
changed = (
{k: getattr(incoming, k) for k in cls._field_names
if k != pk_field and getattr(incoming, k) != getattr(current, k)}
if current
else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field}
)
nested: dict[str, NestedDiff] = {}
for name, shape_cls in cls._nested.items():
incoming_items = getattr(incoming, name, None) or []
current_items = (getattr(current, name, None) or []) if current else []
if not isinstance(incoming_items, list):
incoming_items = [incoming_items]
if not isinstance(current_items, list):
current_items = [current_items]
current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None}
incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None}
nested[name] = NestedDiff(
created=[c for c in incoming_items if shape_cls._get_pk(c) is None],
updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]],
deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk],
)
return Diff(is_new=current is None, changed=changed, _nested=nested)
def diff(self, session: Session) -> "Diff":
"""Diff this shape against its current DB row (or `None` if new)."""
cls = type(self)
pk = cls._get_pk(self)
if pk is not None:
pk_col = getattr(cls._model, cls._pk_field)
results = cls.query(session, lambda s: s.where(pk_col == pk))
if not results:
raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist")
current = results[0]
else:
current = None
return cls._diff_one(self, current)
class NestedDiff:
__slots__ = ("created", "updated", "deleted")
def __init__(self, created=(), updated=(), deleted=()):
self.created = list(created)
self.updated = list(updated)
self.deleted = list(deleted)
class Diff:
__slots__ = ("is_new", "changed", "_nested")
def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]):
self.is_new = is_new
self.changed = changed
self._nested = _nested
def nested(self, name: str) -> NestedDiff:
"""Strict access to a nested diff. Raises `KeyError` for an unknown name."""
if name not in self._nested:
valid = ", ".join(sorted(self._nested)) or "(none)"
raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
return self._nested[name]
def __getattr__(self, name: str) -> NestedDiff:
if name.startswith("_"):
raise AttributeError(name)
if name not in self._nested:
valid = ", ".join(sorted(self._nested)) or "(none)"
raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}")
return self._nested[name]

View File

@@ -0,0 +1,80 @@
"""
SSR render path — FastAPI adapter surface over the shared Bun bridge.
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
backend, so instead of Django's `MizanTemplates` veneer this exposes an
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
markup plus the hydration payload the client reads on mount.
Usage:
from mizan_fastapi.ssr import SSRRenderer
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
@app.get("/profile/{user_id}")
async def profile(user_id: int):
return ssr.render("components/Profile.tsx", {"user_id": user_id})
`render` resolves the template name to an absolute file path against `dirs`
(parity with Django's `DIRS`), then renders the component's default export. The
hydration wrapping matches the Django backend byte-for-byte so the same client
bundle hydrates either server.
"""
from __future__ import annotations
import json
import os
from typing import Any
from fastapi.responses import HTMLResponse
from mizan_core.ssr import SSRBridge
class SSRRenderer:
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
serializes worker I/O); a single renderer can be shared across the app.
"""
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
self._dirs = list(dirs or [])
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
def _resolve(self, template_name: str) -> str:
"""Resolve a template name to an absolute file path against `dirs`.
An already-absolute, existing path is used directly; otherwise each `dirs`
entry is tried in order (parity with Django's `DIRS` resolution).
"""
if os.path.isabs(template_name) and os.path.isfile(template_name):
return template_name
for dir_path in self._dirs:
candidate = os.path.join(dir_path, template_name)
if os.path.isfile(candidate):
return os.path.abspath(candidate)
raise FileNotFoundError(
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
)
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
"""Render the component to an HTML string (markup + hydration script)."""
props = dict(props or {})
result = self._bridge.render(self._resolve(template_name), props)
hydration_json = json.dumps(props, sort_keys=True, default=str)
return (
f'<div id="mizan-root">{result.html}</div>'
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
)
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
"""Render the component and return a FastAPI `HTMLResponse`."""
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
def shutdown(self) -> None:
"""Stop the underlying Bun subprocess."""
self._bridge.shutdown()

View File

@@ -0,0 +1,167 @@
"""
Edge-manifest + PSR behavior — the genuine capability behind the
`edge_manifest` and `psr` probes.
Proves the FastAPI adapter emits the manifest the spec defines (contexts,
mutations, params, user_scoped, render_strategy, page_routes) by deriving it from
a real registry, and that `render_strategy` falls out of the user-scoped-param
rule: a context whose params overlap {user_id, user, owner_id, account_id} is
`dynamic_cached`, otherwise `psr`.
"""
from __future__ import annotations
import json
import subprocess
import sys
import pytest
from fastapi.responses import Response
import mizan_fastapi # registers the Starlette Response base for view-path detection
from mizan_core.client.function import client
from mizan_core.registry import clear_registry, register
from mizan_fastapi import edge_manifest, generate_edge_manifest
from mizan_fastapi.manifest import render_strategies
@pytest.fixture(autouse=True)
def _clean_registry():
clear_registry()
yield
clear_registry()
def _register(fn, name):
register(fn, name)
def test_user_scoped_context_is_dynamic_cached():
@client(context="user")
def user_profile(request, user_id: int) -> dict:
return {"id": user_id}
_register(user_profile, "user_profile")
manifest = edge_manifest()
ctx = manifest["contexts"]["user"]
assert ctx["user_scoped"] is True
assert ctx["render_strategy"] == "dynamic_cached"
assert ctx["params"] == ["user_id"]
assert ctx["endpoints"] == ["/api/mizan/ctx/user/"]
def test_non_user_scoped_context_is_psr():
@client(context="catalog")
def catalog_items(request, category: str) -> list[dict]:
return [{"category": category}]
_register(catalog_items, "catalog_items")
ctx = edge_manifest()["contexts"]["catalog"]
assert ctx["user_scoped"] is False
assert ctx["render_strategy"] == "psr"
def test_render_strategies_maps_each_context():
@client(context="user")
def me(request, user_id: int) -> dict:
return {"id": user_id}
@client(context="catalog")
def items(request) -> list[dict]:
return []
_register(me, "me")
_register(items, "items")
strategies = render_strategies()
assert strategies == {"user": "dynamic_cached", "catalog": "psr"}
def test_mutation_records_affects_and_auto_scope():
@client(context="user")
def user_profile(request, user_id: int) -> dict:
return {"id": user_id}
@client(affects="user")
def rename(request, user_id: int, name: str) -> dict:
return {"ok": True}
_register(user_profile, "user_profile")
_register(rename, "rename")
mutation = edge_manifest()["mutations"]["rename"]
assert mutation["affects"] == ["user"]
# user_id matches the context's param → auto-scoped
assert mutation["auto_scoped_params"] == ["user_id"]
def test_private_and_route_mutation_carried():
@client(affects="subscription", private=True, route="/webhooks/stripe/", methods=["POST"])
def stripe_webhook(request) -> Response:
return Response(status_code=200)
@client(context="subscription")
def subscription(request, user_id: int) -> dict:
return {"id": user_id}
_register(stripe_webhook, "stripe_webhook")
_register(subscription, "subscription")
mutation = edge_manifest()["mutations"]["stripe_webhook"]
assert mutation["private"] is True
assert mutation["route"] == "/webhooks/stripe/"
assert mutation["methods"] == ["POST"]
def test_view_path_function_records_route_and_page_routes():
@client(context="profile", route="/profile/<user_id>/")
def profile_page(request, user_id: int) -> Response:
return Response(status_code=200)
_register(profile_page, "profile_page")
ctx = edge_manifest()["contexts"]["profile"]
assert ctx["page_routes"] == ["/profile/<user_id>/"]
fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page")
assert fn_entry["path"] == "view"
assert fn_entry["route"] == "/profile/<user_id>/"
def test_fastapi_manifest_matches_core_derivation():
"""The adapter callable is a thin pass-through to the shared core derivation."""
@client(context="user")
def user_profile(request, user_id: int) -> dict:
return {"id": user_id}
_register(user_profile, "user_profile")
assert edge_manifest() == generate_edge_manifest(base_url="/api/mizan")
def test_cli_entry_emits_manifest_json(tmp_path):
"""`mizan-fastapi-edge-manifest <module>` imports the module then prints JSON."""
app_module = tmp_path / "manifest_app.py"
app_module.write_text(
"from mizan_core.client.function import client\n"
"from mizan_core.registry import register\n"
"@client(context='user')\n"
"def user_profile(request, user_id: int) -> dict:\n"
" return {'id': user_id}\n"
"register(user_profile, 'user_profile')\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, "-m", "mizan_fastapi.manifest", "manifest_app", "--indent", "0"],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
manifest = json.loads(result.stdout)
assert manifest["contexts"]["user"]["render_strategy"] == "dynamic_cached"

View File

@@ -0,0 +1,145 @@
"""
Forms behavior — the genuine capability behind the `forms` probe.
Proves the Pydantic binding exposes the same schema / validate / submit role
contract as the Django adapter: subclassing `mizanForm` auto-registers
`{name}.schema`, `{name}.validate`, `{name}.submit` with the matching
`_meta["form_role"]`, the schema role emits typed field definitions, validate
returns structured field errors, and submit validates then runs
`on_submit_success` / `on_submit_failure`.
"""
from __future__ import annotations
import pytest
from mizan_core.registry import clear_registry, get_function
from mizan_fastapi.forms import (
FormConfig,
FormSubmitFail,
FormSubmitPass,
FormValidation,
build_form_schema,
get_forms,
mizanForm,
)
@pytest.fixture(autouse=True)
def _clean():
clear_registry()
yield
clear_registry()
def _make_contact_form():
class ContactForm(mizanForm):
mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send")
name: str
email: str
message: str = ""
def on_submit_success(self, request) -> dict:
return {"sent": True, "to": self.email}
return ContactForm
def test_subclassing_registers_three_role_functions():
_make_contact_form()
for role in ("schema", "validate", "submit"):
fn = get_function(f"contact.{role}")
assert fn is not None, f"contact.{role} not registered"
assert fn._meta["form"] is True
assert fn._meta["form_name"] == "contact"
assert fn._meta["form_role"] == role
def test_schema_role_emits_field_definitions():
form_cls = _make_contact_form()
SchemaFn = get_function("contact.schema")
schema = SchemaFn(request=None).call(None)
assert schema.name == "contact"
assert schema.title == "Contact Us"
assert schema.submit_label == "Send"
field_names = {f.name for f in schema.fields}
assert field_names == {"name", "email", "message"}
# `message` has a default → not required; `name`/`email` required
by_name = {f.name: f for f in schema.fields}
assert by_name["name"].required is True
assert by_name["message"].required is False
def test_build_form_schema_maps_types():
class TypedForm(mizanForm):
mizan = FormConfig(name="typed")
count: int
ratio: float
active: bool
label: str
schema = build_form_schema(TypedForm)
by_name = {f.name: f for f in schema.fields}
assert by_name["count"].type == "number"
assert by_name["ratio"].type == "number"
assert by_name["active"].type == "checkbox"
assert by_name["label"].type == "text"
def test_validate_role_passes_clean_data():
_make_contact_form()
ValidateFn = get_function("contact.validate")
ValidateInput = ValidateFn.Input
out = ValidateFn(request=None).call(ValidateInput(data={"name": "Ryth", "email": "r@x.com"}))
assert isinstance(out, FormValidation)
assert out.errors == []
def test_validate_role_reports_field_errors():
_make_contact_form()
ValidateFn = get_function("contact.validate")
ValidateInput = ValidateFn.Input
out = ValidateFn(request=None).call(ValidateInput(data={"email": "r@x.com"})) # missing 'name'
error_fields = {e.field for e in out.errors}
assert "name" in error_fields
def test_submit_role_runs_on_submit_success():
_make_contact_form()
SubmitFn = get_function("contact.submit")
SubmitInput = SubmitFn.Input
result = SubmitFn(request=None).call(
SubmitInput(data={"name": "Ryth", "email": "ryth@example.com", "message": "hi"})
)
assert isinstance(result, FormSubmitPass)
assert result.success is True
assert result.data == {"sent": True, "to": "ryth@example.com"}
def test_submit_role_returns_fail_on_invalid():
captured = {}
class GuardedForm(mizanForm):
mizan = FormConfig(name="guarded")
name: str
def on_submit_failure(self, request, errors) -> None:
captured["errors"] = errors
SubmitFn = get_function("guarded.submit")
SubmitInput = SubmitFn.Input
result = SubmitFn(request=None).call(SubmitInput(data={})) # missing required 'name'
assert isinstance(result, FormSubmitFail)
assert result.success is False
assert any(e.field == "name" for e in result.errors.errors)
# on_submit_failure hook fired with the validation
assert "errors" in captured
def test_get_forms_groups_by_form_name():
_make_contact_form()
forms = get_forms()
assert set(forms.keys()) == {"contact"}
assert len(forms["contact"]) == 3

View File

@@ -0,0 +1,269 @@
"""
Shapes behavior — the genuine capability behind the `shapes` probe.
Proves the SQLAlchemy binding has the same Shape declaration surface and
projection/diff semantics as the Django `django-readers` binding:
- `Shape[Model]` resolves the mapped model + PK from the generic arg;
- scalar annotations project columns, Shape-typed annotations project relations;
- `.query(session, *stmt_fns, **relation_stmt)` flat / nested / scoped;
- nested loads stay flat (selectinload, not N+1);
- `.diff()` / `.diff_many()` detect field changes + nested created/updated/deleted.
"""
from __future__ import annotations
import pytest
from sqlalchemy import ForeignKey, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
from mizan_fastapi.shapes import Diff, NestedDiff, Shape
# ─── Mapped models ────────────────────────────────────────────────────────────
class Base(DeclarativeBase):
pass
class Publisher(Base):
__tablename__ = "publisher"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
country: Mapped[str]
authors: Mapped[list["Author"]] = relationship(back_populates="publisher")
class Author(Base):
__tablename__ = "author"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
bio: Mapped[str] = mapped_column(default="")
publisher_id: Mapped[int] = mapped_column(ForeignKey("publisher.id"))
publisher: Mapped[Publisher] = relationship(back_populates="authors")
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "book"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
is_published: Mapped[bool] = mapped_column(default=True)
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"))
author: Mapped[Author] = relationship(back_populates="books")
# ─── Shapes (declaration surface identical to the Django adapter) ──────────────
class FlatAuthorShape(Shape[Author]):
id: int | None = None
name: str
class FlatBookShape(Shape[Book]):
id: int | None = None
title: str
is_published: bool
class BookCardShape(Shape[Book]):
id: int | None = None
title: str
is_published: bool
author: FlatAuthorShape # single nested FK
class AuthorCardShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[FlatBookShape] = [] # list nested reverse-FK
class PublisherDetailShape(Shape[Publisher]):
id: int | None = None
name: str
authors: list[AuthorCardShape] = [] # 3-level nesting
# ─── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def session():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
pub = Publisher(name="Orbit", country="UK")
ann = Author(name="Ann Leckie", bio="Imperial Radch", publisher=pub)
devi = Author(name="Devi Pillai", bio="", publisher=pub)
ann.books = [
Book(title="Ancillary Justice", is_published=True),
Book(title="Provenance", is_published=False),
]
s.add_all([pub, ann, devi])
s.commit()
yield s
# ─── Declaration ────────────────────────────────────────────────────────────────
def test_shape_resolves_model_and_pk():
assert FlatAuthorShape._model is Author
assert FlatAuthorShape._pk_field == "id"
def test_flat_shape_has_no_nested():
assert FlatAuthorShape._nested == {}
assert FlatAuthorShape._field_names == ["id", "name"]
def test_single_nested_detected():
assert BookCardShape._nested == {"author": FlatAuthorShape}
def test_list_nested_detected():
assert AuthorCardShape._nested == {"books": FlatBookShape}
# ─── Query ──────────────────────────────────────────────────────────────────────
def test_flat_query_projects_fields(session):
authors = FlatAuthorShape.query(session)
assert len(authors) == 2
assert {a.name for a in authors} == {"Ann Leckie", "Devi Pillai"}
def test_query_with_stmt_fn_filters(session):
authors = FlatAuthorShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
assert [a.name for a in authors] == ["Ann Leckie"]
def test_single_nested_fk_projected(session):
books = BookCardShape.query(session, lambda s: s.where(Book.title == "Ancillary Justice"))
assert len(books) == 1
assert books[0].author.name == "Ann Leckie"
def test_list_nested_reverse_fk_projected(session):
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Ann Leckie"))
assert len(authors) == 1
assert {b.title for b in authors[0].books} == {"Ancillary Justice", "Provenance"}
def test_empty_nested_list(session):
authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Devi Pillai"))
assert authors[0].books == []
def test_three_level_nesting(session):
pubs = PublisherDetailShape.query(session)
assert len(pubs) == 1
leckie = next(a for a in pubs[0].authors if a.name == "Ann Leckie")
assert len(leckie.books) == 2
def test_relation_stmt_scopes_nested_load(session):
authors = AuthorCardShape.query(
session,
lambda s: s.where(Author.name == "Ann Leckie"),
books=lambda s: s.where(Book.is_published.is_(True)),
)
assert [b.title for b in authors[0].books] == ["Ancillary Justice"]
assert all(b.is_published for b in authors[0].books)
def test_nested_query_stays_flat(session):
"""selectinload keeps the projection at O(depth) queries, not N+1."""
counter = {"n": 0}
@event.listens_for(session.bind, "after_cursor_execute")
def _count(*args):
counter["n"] += 1
AuthorCardShape.query(session)
# one query for authors + one selectin for books
assert counter["n"] == 2
# ─── Diff ─────────────────────────────────────────────────────────────────────
def test_diff_no_changes(session):
book = session.query(Book).filter_by(title="Ancillary Justice").one()
shape = FlatBookShape(id=book.id, title="Ancillary Justice", is_published=True)
d = shape.diff(session)
assert d.is_new is False
assert d.changed == {}
def test_diff_detects_field_change(session):
book = session.query(Book).filter_by(title="Ancillary Justice").one()
shape = FlatBookShape(id=book.id, title="Ancillary Justice (rev)", is_published=True)
d = shape.diff(session)
assert d.changed["title"] == "Ancillary Justice (rev)"
def test_diff_new_item(session):
shape = FlatBookShape(id=None, title="Elantris", is_published=True)
d = shape.diff(session)
assert d.is_new is True
assert "title" in d.changed
def test_diff_nonexistent_pk_raises(session):
shape = FlatBookShape(id=999999, title="Ghost", is_published=False)
with pytest.raises(LookupError):
shape.diff(session)
def test_nested_diff_created_updated_deleted(session):
author = session.query(Author).filter_by(name="Ann Leckie").one()
books = sorted(author.books, key=lambda b: b.title)
# keep one (updated), drop one (deleted), add one (created)
shape = AuthorCardShape(
id=author.id,
name="Ann Leckie",
bio="Imperial Radch",
books=[
FlatBookShape(id=books[0].id, title="Ancillary Justice REWRITTEN", is_published=True),
FlatBookShape(id=None, title="Ancillary Sword", is_published=True),
],
)
d = shape.diff(session)
assert len(d.books.updated) == 1
assert len(d.books.created) == 1
assert len(d.books.deleted) == 1
def test_diff_strict_nested_access_raises_on_typo(session):
author = session.query(Author).filter_by(name="Ann Leckie").one()
shape = FlatAuthorShape(id=author.id, name="Ann Leckie")
d = shape.diff(session)
with pytest.raises(AttributeError):
_ = d.bookz
with pytest.raises(KeyError):
d.nested("bookz")
def test_diff_many_batches(session):
books = session.query(Book).all()
items = [FlatBookShape(id=b.id, title=b.title + "!", is_published=b.is_published) for b in books]
results = FlatBookShape.diff_many(session, items)
assert len(results) == len(books)
assert all("title" in d.changed for _, d in results)
def test_diff_many_mixed_new_and_existing(session):
book = session.query(Book).first()
items = [
FlatBookShape(id=book.id, title=book.title, is_published=book.is_published),
FlatBookShape(id=None, title="Brand New", is_published=False),
]
results = FlatBookShape.diff_many(session, items)
assert sum(1 for _, d in results if d.is_new) == 1
assert sum(1 for _, d in results if not d.is_new) == 1

View File

@@ -0,0 +1,138 @@
"""
SSR behavior — the genuine capability behind the `ssr_bridge` probe.
The SSR subprocess lifecycle + JSON-RPC protocol live in the shared
`mizan_core.ssr.SSRBridge`; the FastAPI `SSRRenderer` resolves a component path
against `dirs`, drives the bridge, and wraps the result with the hydration script
the client reads on mount.
Bun is not assumed present in CI, so the bridge is driven against a stand-in
worker that speaks the SAME newline-delimited JSON-RPC protocol (ready signal +
`render` → `{id, html}`). That exercises the real bridge code path (spawn,
message-ID correlation, threaded reader) — only the renderer binary is swapped.
The path-resolution and hydration-wrapping are tested directly.
"""
from __future__ import annotations
import sys
import textwrap
import pytest
from mizan_core.ssr import SSRBridge
from mizan_fastapi.ssr import SSRRenderer
# A Python stand-in for the Bun worker: emits the ready signal, then for each
# render request echoes a deterministic HTML fragment built from the props.
_FAKE_WORKER = textwrap.dedent(
"""
import json, sys
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)
if msg.get("method") == "render":
props = msg["params"]["props"]
html = "<p>" + props.get("name", "") + "</p>"
sys.stdout.write(json.dumps({"id": msg["id"], "html": html}) + "\\n")
sys.stdout.flush()
"""
)
@pytest.fixture
def fake_worker(tmp_path):
worker = tmp_path / "fake_worker.py"
worker.write_text(_FAKE_WORKER, encoding="utf-8")
return str(worker)
@pytest.fixture
def python_bridge(fake_worker, monkeypatch):
"""An `SSRBridge` whose subprocess is python (not bun), driving the fake worker."""
import subprocess
real_popen = subprocess.Popen
def fake_popen(cmd, *args, **kwargs):
# Swap the `bun run <worker>` invocation for `python <worker>`.
if cmd[:2] == ["bun", "run"]:
cmd = [sys.executable, cmd[2]]
return real_popen(cmd, *args, **kwargs)
monkeypatch.setattr(subprocess, "Popen", fake_popen)
bridge = SSRBridge(worker_path=fake_worker, timeout=5.0)
yield bridge
bridge.shutdown()
def test_bridge_round_trips_render(python_bridge):
result = python_bridge.render("/abs/Hello.tsx", {"name": "World"})
assert result.html == "<p>World</p>"
def test_bridge_correlates_concurrent_renders(python_bridge):
# Two renders on the persistent subprocess return their own results.
a = python_bridge.render("/abs/A.tsx", {"name": "A"})
b = python_bridge.render("/abs/B.tsx", {"name": "B"})
assert (a.html, b.html) == ("<p>A</p>", "<p>B</p>")
def test_renderer_resolves_against_dirs_and_wraps_hydration(fake_worker, monkeypatch, tmp_path):
import subprocess
real_popen = subprocess.Popen
monkeypatch.setattr(
subprocess, "Popen",
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
)
components = tmp_path / "frontend"
components.mkdir()
(components / "Hello.tsx").write_text("export default () => null", encoding="utf-8")
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
try:
html = renderer.render_to_string("Hello.tsx", {"name": "Mizan"})
finally:
renderer.shutdown()
assert '<div id="mizan-root"><p>Mizan</p></div>' in html
assert 'window.__MIZAN_SSR_DATA__={"name": "Mizan"}' in html
def test_renderer_returns_html_response(fake_worker, monkeypatch, tmp_path):
import subprocess
from fastapi.responses import HTMLResponse
real_popen = subprocess.Popen
monkeypatch.setattr(
subprocess, "Popen",
lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k),
)
components = tmp_path / "frontend"
components.mkdir()
(components / "Card.tsx").write_text("export default () => null", encoding="utf-8")
renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)])
try:
response = renderer.render("Card.tsx", {"name": "x"})
finally:
renderer.shutdown()
assert isinstance(response, HTMLResponse)
assert response.status_code == 200
def test_renderer_raises_on_missing_component(fake_worker, tmp_path):
renderer = SSRRenderer(worker=fake_worker, dirs=[str(tmp_path)])
try:
with pytest.raises(FileNotFoundError):
renderer.render_to_string("Nope.tsx", {})
finally:
renderer.shutdown()

View File

@@ -0,0 +1,145 @@
"""
WebSocket RPC behavior — the genuine capability behind the `websocket` probe.
Proves the `/ws/` route dispatches `@client(websocket=True)` functions through
the SAME `mizan_core.dispatch` core as `POST /call/`: input validation, the
`{result, invalidate, merge}` envelope, `auth=` enforcement, and the
websocket=True gate that rejects HTTP-only functions. The frame protocol matches
mizan-django's Channels consumer (`action:"rpc"` → `{id, ok, data|error}`).
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import clear_registry, register
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
class EchoOut(BaseModel):
message: str
@pytest.fixture
def app():
clear_registry()
@client(websocket=True)
def ws_echo(request, text: str) -> EchoOut:
return EchoOut(message=f"ws: {text}")
@client(websocket=True)
def ws_add(request, a: int, b: int) -> dict:
return {"total": a + b}
@client(websocket=True, affects="user")
def ws_update(request, user_id: int) -> dict:
return {"ok": True}
@client(websocket=True, auth=True)
def ws_secret(request) -> dict:
return {"secret": True}
@client # HTTP-only — must be rejected over WS
def http_only(request) -> dict:
return {"http": True}
for fn, name in (
(ws_echo, "ws_echo"), (ws_add, "ws_add"), (ws_update, "ws_update"),
(ws_secret, "ws_secret"), (http_only, "http_only"),
):
register(fn, name)
fastapi_app = FastAPI()
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
yield fastapi_app
clear_registry()
@pytest.fixture
def http(app):
return TestClient(app)
def test_ws_rpc_dispatches_and_returns_data(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "1", "fn": "ws_echo", "args": {"text": "hi"}})
frame = ws.receive_json()
assert frame == {"id": "1", "ok": True, "data": {"message": "ws: hi"}, "invalidate": []}
def test_ws_rpc_validates_input_through_core(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "2", "fn": "ws_add", "args": {"a": "nope", "b": 3}})
frame = ws.receive_json()
assert frame["ok"] is False
assert frame["error"]["code"] == "VALIDATION_ERROR"
def test_ws_rpc_carries_invalidation(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "3", "fn": "ws_update", "args": {"user_id": 5}})
frame = ws.receive_json()
assert frame["ok"] is True
assert "user" in frame["invalidate"]
def test_http_only_function_is_forbidden_over_ws(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "4", "fn": "http_only", "args": {}})
frame = ws.receive_json()
assert frame["ok"] is False
assert frame["error"]["code"] == "FORBIDDEN"
def test_unknown_function_over_ws_is_not_found(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "5", "fn": "ghost", "args": {}})
frame = ws.receive_json()
assert frame["ok"] is False
assert frame["error"]["code"] == "NOT_FOUND"
def test_auth_required_function_rejects_anonymous_over_ws(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "6", "fn": "ws_secret", "args": {}})
frame = ws.receive_json()
assert frame["ok"] is False
assert frame["error"]["code"] == "UNAUTHORIZED"
def test_missing_fn_field_is_bad_request(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "7"})
frame = ws.receive_json()
assert frame["ok"] is False
assert frame["error"]["code"] == "BAD_REQUEST"
def test_unknown_action_errors(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "bogus"})
frame = ws.receive_json()
assert "error" in frame
def test_multiple_calls_on_one_connection(http):
with http.websocket_connect("/api/mizan/ws/") as ws:
ws.send_json({"action": "rpc", "id": "a", "fn": "ws_echo", "args": {"text": "1"}})
first = ws.receive_json()
ws.send_json({"action": "rpc", "id": "b", "fn": "ws_echo", "args": {"text": "2"}})
second = ws.receive_json()
assert first["data"]["message"] == "ws: 1"
assert second["data"]["message"] == "ws: 2"

View File

@@ -27,6 +27,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
@@ -38,6 +39,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@@ -45,8 +47,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -74,18 +78,90 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[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 = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[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 = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[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 = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -110,6 +186,23 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
@@ -123,17 +216,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[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 = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[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 = "http"
version = "1.4.0"
@@ -286,10 +411,15 @@ name = "mizan-axum"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"futures-util",
"http-body-util",
"mizan-core",
"multer",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tower",
"tower-http",
]
@@ -299,10 +429,13 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"hmac",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -315,6 +448,23 @@ dependencies = [
"syn",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -333,6 +483,15 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -351,6 +510,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -429,6 +618,28 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[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 = "slab"
version = "0.4.12"
@@ -451,6 +662,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[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"
@@ -468,12 +691,33 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
@@ -493,6 +737,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -557,12 +813,48 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -584,6 +876,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"

View File

@@ -7,9 +7,17 @@ license = "Elastic-2.0"
[dependencies]
mizan-core = { path = "../../cores/mizan-rust" }
axum = "0.7"
axum = { version = "0.7", features = ["ws", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
futures-util = "0.3"
multer = "3"
base64 = "0.22"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tokio-tungstenite = "0.24"
http-body-util = "0.1"

View File

@@ -0,0 +1,89 @@
//! Forms endpoints — schema / validate / submit over the registered form
//! functions. The Forms capability is AFI-common; the binding is
//! per-framework (Django Forms on Django, a `#[mizan(form_name=…,
//! form_role=…)]` function here). A form is the set of registered functions
//! sharing a `form_name`, each carrying one `form_role`; each role gets its
//! own route that dispatches the function whose `(form_name, form_role)`
//! matches.
//!
//! POST /form/:form_name/schema/
//! POST /form/:form_name/validate/
//! POST /form/:form_name/submit/
use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS};
use serde_json::{Map, Value};
use std::sync::Arc;
use crate::errors::ApiError;
use crate::state::MizanState;
/// Find the registered form function with this `(form_name, form_role)`.
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
}
/// Dispatch the form function for `(form_name, role)`. Shared by the three
/// role routes below.
async fn dispatch_role(
state: &MizanState,
form_name: &str,
role: &str,
args: Value,
) -> Result<Response, ApiError> {
let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"no form {form_name:?} with role {role:?}"
)))
})?;
let args_value = match args {
Value::Object(_) | Value::Null => args,
other => Value::Object({
let mut m = Map::new();
m.insert("data".into(), other);
m
}),
};
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?;
let mut resp = (StatusCode::OK, Json(result)).into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(resp)
}
/// POST /form/:form_name/schema/ — the form's field/schema descriptor.
pub async fn form_schema(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "schema", args).await
}
/// POST /form/:form_name/validate/ — validate submitted data without committing.
pub async fn form_validate(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "validate", args).await
}
/// POST /form/:form_name/submit/ — validate and commit the form.
pub async fn form_submit(
State(state): State<Arc<MizanState>>,
Path(form_name): Path<String>,
Json(args): Json<Value>,
) -> Result<Response, ApiError> {
dispatch_role(&state, &form_name, "submit", args).await
}

View File

@@ -1,25 +1,22 @@
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`.
//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`
//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic.
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mizan_core::{
compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec,
authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header,
lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity,
InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::errors::ApiError;
/// Type-erased application state threaded into every `dispatch()` call via
/// `RequestHandle`. User handlers downcast to their concrete state type.
/// `Arc` keeps the clone cheap across per-request handler invocations.
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
use crate::state::MizanState;
/// Body for POST /call/. Matches the Python `CallBody` shape.
#[derive(Debug, Deserialize)]
@@ -33,9 +30,7 @@ pub struct CallBody {
impl CallBody {
fn resolved_name(&self) -> Option<&str> {
self.function_name
.as_deref()
.or(self.fn_.as_deref())
self.function_name.as_deref().or(self.fn_.as_deref())
}
}
@@ -54,44 +49,210 @@ fn no_store(json: Value) -> Response {
resp
}
/// POST /call/ — RPC dispatch.
/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer`
/// through the shared `authenticate`. A present-but-invalid token rejects with
/// 401 (the `INVALID` contract); no token → anonymous (`None`).
pub(crate) fn identity_from_headers(
headers: &HeaderMap,
state: &MizanState,
) -> Result<Option<Identity>, ApiError> {
let mwt = headers
.get("X-Mizan-Token")
.and_then(|v| v.to_str().ok());
let bearer = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) {
AuthOutcome::Authenticated(id) => Ok(Some(id)),
AuthOutcome::Anonymous => Ok(None),
AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized(
"Invalid or expired token".into(),
))),
}
}
/// Enforce a function's `@client(auth=...)` against the resolved identity.
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> {
let req = AuthRequirement::from_str_opt(fn_spec.auth());
enforce_auth(identity, &req).map_err(ApiError)
}
/// Reject a client call into a `private` function (no RPC endpoint).
fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> {
if fn_spec.private() {
return Err(ApiError(MizanError::Forbidden(
"Function is not client-callable".into(),
)));
}
Ok(())
}
fn uid_str(identity: Option<&Identity>) -> Option<String> {
identity.map(|i| i.user_id.clone())
}
/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body
/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the
/// invalidated contexts.
pub async fn function_call(
State(app_state): State<AppStateAny>,
Json(body): Json<CallBody>,
State(state): State<Arc<MizanState>>,
headers: HeaderMap,
body: axum::body::Body,
) -> Result<Response, ApiError> {
let fn_name = body
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
let identity = identity_from_headers(&headers, &state)?;
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let fn_spec = lookup_function(&fn_name)
.ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?;
let (fn_name, args) = if content_type.starts_with("multipart/form-data") {
parse_multipart(&content_type, body).await?
} else {
parse_json_call(body).await?
};
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?;
let fn_spec = lookup_function(&fn_name).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
reject_if_private(fn_spec)?;
guard(fn_spec, identity.as_ref())?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &body.args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &body.args, &result);
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ApiError)?;
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
// Purge the origin cache for everything this mutation invalidated.
if !targets.is_empty() {
state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref());
}
let payload = CallResponse {
result,
invalidate,
merge: merge_payload,
};
Ok(no_store(serde_json::to_value(&payload).unwrap()))
let mut resp = no_store(serde_json::to_value(&payload).unwrap());
if !targets.is_empty() {
let header_val = format_invalidate_header(&targets);
if let Ok(hv) = HeaderValue::from_str(&header_val) {
resp.headers_mut().insert("X-Mizan-Invalidate", hv);
}
}
Ok(resp)
}
/// GET /ctx/:context_name/ — bundled context fetch.
async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map<String, Value>), ApiError> {
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?;
let call: CallBody = serde_json::from_slice(&bytes)
.map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?;
let fn_name = call
.resolved_name()
.ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))?
.to_string();
Ok((fn_name, call.args))
}
/// Parse a multipart `/call/` request: a JSON `args` field plus file parts.
/// Each file part binds into the matching Upload-typed input field as a
/// base64-carrying value the `mizan_core::Upload` field deserializes.
async fn parse_multipart(
content_type: &str,
body: axum::body::Body,
) -> Result<(String, Map<String, Value>), ApiError> {
let boundary = multer::parse_boundary(content_type)
.map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?;
let stream = body.into_data_stream();
let mut mp = multer::Multipart::new(stream, boundary);
let mut fn_name: Option<String> = None;
let mut args: Map<String, Value> = Map::new();
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
while let Some(field) = mp
.next_field()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))?
{
let name = field.name().unwrap_or("").to_string();
let filename = field.file_name().map(|s| s.to_string());
let part_content_type = field.content_type().map(|s| s.to_string());
if filename.is_some() {
// A file part → the JSON shape `mizan_core::Upload` deserializes
// (filename, content_type, base64 bytes).
let data = field
.bytes()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?;
files.entry(name).or_default().push(uploaded_file_json(
filename,
part_content_type,
&data,
));
} else {
let text = field
.text()
.await
.map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?;
if name == "fn" {
fn_name = Some(text);
} else if name == "args" {
let parsed: Value = serde_json::from_str(&text).map_err(|_| {
ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into()))
})?;
if let Value::Object(m) = parsed {
args = m;
}
}
}
}
// Bind file parts into args by field name (single vs list).
for (field_name, parts) in files {
if parts.len() == 1 {
args.insert(field_name, parts.into_iter().next().unwrap());
} else {
args.insert(field_name, Value::Array(parts));
}
}
let fn_name =
fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?;
Ok((fn_name, args))
}
/// Encode a received file part as the JSON shape an `Upload` field expects.
fn uploaded_file_json(filename: Option<String>, content_type: Option<String>, data: &[u8]) -> Value {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
serde_json::json!({
"filename": filename,
"content_type": content_type,
"data_b64": STANDARD.encode(data),
"size": data.len(),
})
}
/// GET /ctx/:context_name/ — bundled context fetch, origin-cached.
pub async fn context_fetch(
State(app_state): State<AppStateAny>,
State(state): State<Arc<MizanState>>,
headers: HeaderMap,
Path(context_name): Path<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
@@ -101,6 +262,8 @@ pub async fn context_fetch(
))));
}
let identity = identity_from_headers(&headers, &state)?;
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
@@ -112,22 +275,130 @@ pub async fn context_fetch(
))));
}
// Convert query params (all-string values) to the JSON arg map. Numeric
// params get parsed via the per-function input_params primitive table.
// Origin cache: the canonical-JSON bundle body is keyed by (context,
// params, user, rev). The Rust IR carries no per-fn rev yet → rev 0.
let cache_params: BTreeMap<String, Value> = params
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
let uid = uid_str(identity.as_ref());
if let Some(cached) = state
.cache
.get(&context_name, &cache_params, uid.as_deref(), 0)
{
return Ok(cached_response(cached, "HIT"));
}
// Enforce auth per member (the bundle is only as open as its strictest fn).
let mut bundled = Map::new();
for fn_spec in &members {
guard(*fn_spec, identity.as_ref())?;
let args = coerce_query_args(*fn_spec, &params);
let req = RequestHandle::from_dyn(app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?;
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ApiError)?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(no_store(Value::Object(bundled)))
let body = canonical_bytes(&Value::Object(bundled));
let status = if state.cache.enabled() {
state
.cache
.put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0);
"MISS"
} else {
""
};
Ok(cached_response(body, status))
}
/// Coerce string-valued query params into typed JSON values using the
/// function's declared input_params. Strings that don't parse stay as
/// strings — the dispatch wrapper will raise ValidationFailed downstream.
/// Canonical JSON bytes for the cache body — sorted keys, matching Python's
/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible.
fn canonical_bytes(v: &Value) -> Vec<u8> {
fn sort(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(&m[k]));
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(sort).collect()),
other => other.clone(),
}
}
// Python's default separators add a space after ':' and ','. Match that so
// a Rust-written cache body and a Python-written one are byte-equal.
let sorted = sort(v);
python_json(&sorted)
}
/// Serialize like Python `json.dumps(sort_keys=True)` default separators
/// (`", "` and `": "`).
fn python_json(v: &Value) -> Vec<u8> {
let compact = serde_json::to_string(v).unwrap();
// serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults.
// This is a structural transform on the already-sorted value, so the
// bytes match `json.dumps` for the JSON value space Mizan returns.
let spaced = respace(&compact);
spaced.into_bytes()
}
/// Insert the spaces Python's default `json.dumps` uses after structural
/// `,`/`:` — but only outside string literals.
fn respace(s: &str) -> String {
let mut out = String::with_capacity(s.len() + s.len() / 8);
let mut in_str = false;
let mut escaped = false;
for c in s.chars() {
if in_str {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
',' => out.push_str(", "),
':' => out.push_str(": "),
_ => out.push(c),
}
}
out
}
fn cached_response(body: Vec<u8>, cache_status: &str) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
if !cache_status.is_empty() {
if let Ok(v) = HeaderValue::from_str(cache_status) {
h.insert("X-Mizan-Cache", v);
}
}
resp
}
/// Coerce string-valued query params into typed JSON via the function's
/// declared input_params.
fn coerce_query_args(
fn_spec: &dyn FunctionSpec,
params: &BTreeMap<String, String>,
@@ -137,28 +408,88 @@ fn coerce_query_args(
if let Some(raw) = params.get(ip.name) {
let parsed = match ip.primitive {
mizan_core::Primitive::Integer => raw.parse::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Number => raw
.parse::<f64>()
.ok()
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)),
mizan_core::Primitive::Boolean => raw.parse::<bool>().ok().map(Value::from),
mizan_core::Primitive::String => Some(Value::from(raw.clone())),
};
if let Some(v) = parsed {
out.insert(ip.name.into(), v);
} else {
out.insert(ip.name.into(), Value::from(raw.clone()));
}
out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone())));
}
}
out
}
/// GET /session/ — the AFI-common session-init endpoint, wired at parity with
/// mizan-django and mizan-fastapi. The CSRF *token* is a Django session
/// mechanism with no Rust equivalent, so this returns a null token; the endpoint
/// itself is owed and present, and readiness-probe consumers get a well-formed
/// response.
/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session
/// mechanism; the endpoint here returns a null token and serves as the
/// readiness probe the wire-parity harness uses.
pub async fn session_init() -> Response {
let body = serde_json::json!({ "csrfToken": null });
no_store(body)
no_store(serde_json::json!({ "csrfToken": null }))
}
/// GET /manifest/ — emit the edge manifest (contexts + render_strategy +
/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch
/// it. Rides the shared `mizan_core::generate_edge_manifest`.
pub async fn edge_manifest(State(state): State<Arc<MizanState>>) -> Response {
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
no_store(manifest)
}
/// GET /psr/:context_name/ — the PSR descriptor for one context: its
/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or
/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge
/// re-renders. This is the adapter telling Edge *how* to cache each context —
/// the PSR half of the manifest, addressable per-context.
pub async fn psr_descriptor(
State(state): State<Arc<MizanState>>,
Path(context_name): Path<String>,
) -> Result<Response, ApiError> {
let manifest = mizan_core::generate_edge_manifest(&state.base_url);
let ctx = manifest
.get("contexts")
.and_then(|c| c.get(&context_name))
.ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"context {context_name:?} not in manifest"
)))
})?;
let render_strategy = ctx
.get("render_strategy")
.cloned()
.unwrap_or(Value::Null);
let page_routes = ctx
.get("page_routes")
.cloned()
.unwrap_or_else(|| Value::Array(Vec::new()));
Ok(no_store(serde_json::json!({
"context": context_name,
"render_strategy": render_strategy,
"page_routes": page_routes,
})))
}
/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's
/// output, derived from the registered type graph by `mizan_core::shapes`.
pub async fn shape_projection(Path(fn_name): Path<String>) -> Result<Response, ApiError> {
let proj = shapes::project_function_output(&fn_name).ok_or_else(|| {
ApiError(MizanError::NotFound(format!(
"no shape projection for {fn_name:?}"
)))
})?;
Ok(no_store(projection_to_json(&proj)))
}
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
let mut fields = Vec::new();
for f in &proj.fields {
match f {
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
shapes::ShapeField::Nested(n, sub) => {
fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) }));
}
}
}
serde_json::json!({ "type": proj.type_name, "fields": fields })
}

View File

@@ -1,58 +1,80 @@
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry.
//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry,
//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest).
//!
//! Usage:
//! ```ignore
//! use axum::Router;
//! use mizan_axum::router;
//! use mizan_axum::{router, MizanState};
//!
//! #[tokio::main]
//! async fn main() {
//! let app = Router::new().nest("/api/mizan", router());
//! let state = MizanState::builder()
//! .app_state(MyState { /* ... */ })
//! .build();
//! let app = Router::new().nest("/api/mizan", router(state));
//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
//! axum::serve(listener, app).await.unwrap();
//! }
//! ```
//!
//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`):
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch with invalidate+merge response
//! * `GET /ctx/:name/` — bundled context fetch
//! * `GET /session/` — session-init probe (placeholder CSRF token)
//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate
//! * `GET /ctx/:name/` — bundled context fetch (origin-cached)
//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns)
//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations)
//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy)
//! * `GET /shape/:fn/` — typed query projection (Shapes)
//! * `POST /ssr/` — server-side render via the Bun worker
//! * `POST /form/:name/{schema,validate,submit}/` — forms binding
mod errors;
mod forms;
mod handlers;
mod ssr;
mod state;
mod ws;
pub use errors::ApiError;
pub use handlers::{
context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse,
};
pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse};
pub use ssr::{ssr_render, SsrRequest};
pub use state::{AppStateAny, MizanState, MizanStateBuilder};
use axum::routing::{get, post};
use axum::Router;
use std::any::Any;
use std::sync::Arc;
/// Build the Mizan router with user-supplied app state. The state is
/// type-erased into an `Arc<dyn Any + Send + Sync>` and threaded into every
/// dispatch via `RequestHandle`. Handlers downcast to their concrete state
/// type.
///
/// Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(my_state))`.
pub fn router<S>(state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
let state: AppStateAny = Arc::new(state);
/// Build the Mizan router with a fully-configured [`MizanState`] (app state +
/// auth + cache + optional SSR worker). Mount under a prefix:
/// `Router::new().nest("/api/mizan", router(state))`.
pub fn router(state: Arc<MizanState>) -> Router {
Router::new()
.route("/session/", get(handlers::session_init))
.route("/call/", post(handlers::function_call))
.route("/ctx/:context_name/", get(handlers::context_fetch))
.route("/ws/", get(ws::ws_handler))
.route("/manifest/", get(handlers::edge_manifest))
.route("/psr/:context_name/", get(handlers::psr_descriptor))
.route("/shape/:fn_name/", get(handlers::shape_projection))
.route("/ssr/", post(ssr::ssr_render))
.route("/form/:form_name/schema/", post(forms::form_schema))
.route("/form/:form_name/validate/", post(forms::form_validate))
.route("/form/:form_name/submit/", post(forms::form_submit))
.with_state(state)
}
/// Router variant for callers that have no app state to thread — the
/// dispatch path receives a unit-typed handle. Used by the AFI fixture
/// and other stateless test apps.
pub fn router_stateless() -> Router {
router(())
/// Router variant for the common case of just an app state, no auth/cache.
pub fn router_with_state<S>(app_state: S) -> Router
where
S: Any + Send + Sync + 'static,
{
router(MizanState::builder().app_state(app_state).build())
}
/// Router variant for callers that have no app state to thread — the dispatch
/// path receives a unit-typed handle. Used by the AFI fixture and stateless
/// test apps.
pub fn router_stateless() -> Router {
router(MizanState::builder().build())
}

View File

@@ -0,0 +1,50 @@
//! SSR endpoint — drive the Bun renderer through the shared `mizan_core`
//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python
//! `SSRBridge`). The bridge spawns on first render and stays alive.
//!
//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." }
use axum::extract::State;
use axum::response::Response;
use axum::Json;
use mizan_core::MizanError;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use crate::errors::ApiError;
use crate::state::MizanState;
#[derive(Deserialize)]
pub struct SsrRequest {
pub file: String,
#[serde(default)]
pub props: Value,
}
/// POST /ssr/ — render a component file via the Bun SSR worker.
pub async fn ssr_render(
State(state): State<Arc<MizanState>>,
Json(req): Json<SsrRequest>,
) -> Result<Response, ApiError> {
let bridge = state.ssr().ok_or_else(|| {
ApiError(MizanError::NotImplementedYet(
"no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(),
))
})?;
let props = if req.props.is_null() {
json!({})
} else {
req.props
};
let html = bridge
.render(&req.file, props)
.map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?;
let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html })));
resp.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
Ok(resp)
}

View File

@@ -0,0 +1,106 @@
//! Router state — the Mizan config (auth + origin cache) threaded alongside
//! the user's type-erased app state.
//!
//! `app_state` is the consumer's own state, type-erased into `Arc<dyn Any>`
//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to
//! their concrete type — unchanged from the pre-AFI router). `auth` and
//! `cache` are the AFI-common config the handlers read for enforcement and
//! origin caching; an `SsrBridge` is created lazily on the first SSR render.
use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge};
use std::any::Any;
use std::sync::{Arc, OnceLock};
pub type AppStateAny = Arc<dyn Any + Send + Sync>;
/// The full state every Mizan handler receives. Built via [`MizanState::builder`].
pub struct MizanState {
/// The consumer's app state, threaded into dispatch via `RequestHandle`.
pub app_state: AppStateAny,
/// JWT/MWT auth config (token → identity resolution + enforcement).
pub auth: AuthConfig,
/// Origin-side HMAC cache orchestrator (disabled by default).
pub cache: CacheOrchestrator,
/// Mizan API mount point, used by the edge-manifest endpoint.
pub base_url: String,
/// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`.
pub(crate) ssr_worker: Option<String>,
pub(crate) ssr_bridge: OnceLock<SsrBridge>,
}
impl MizanState {
pub fn builder() -> MizanStateBuilder {
MizanStateBuilder::default()
}
/// The SSR bridge, spawned on first use. `None` if no worker was set.
pub fn ssr(&self) -> Option<&SsrBridge> {
let worker = self.ssr_worker.as_ref()?;
Some(
self.ssr_bridge
.get_or_init(|| SsrBridge::bun(worker.clone())),
)
}
}
/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache
/// disabled, `/api/mizan` base URL, no SSR worker.
pub struct MizanStateBuilder {
app_state: AppStateAny,
auth: AuthConfig,
cache: CacheOrchestrator,
base_url: String,
ssr_worker: Option<String>,
}
impl Default for MizanStateBuilder {
fn default() -> Self {
Self {
app_state: Arc::new(()),
auth: AuthConfig::new(),
cache: CacheOrchestrator::disabled(),
base_url: "/api/mizan".to_string(),
ssr_worker: None,
}
}
}
impl MizanStateBuilder {
/// Set the consumer's app state (threaded into dispatch).
pub fn app_state<S: Any + Send + Sync + 'static>(mut self, state: S) -> Self {
self.app_state = Arc::new(state);
self
}
pub fn auth(mut self, auth: AuthConfig) -> Self {
self.auth = auth;
self
}
pub fn cache(mut self, cache: CacheOrchestrator) -> Self {
self.cache = cache;
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
/// Configure the Bun SSR worker path; the bridge spawns on first render.
pub fn ssr_worker(mut self, worker_path: impl Into<String>) -> Self {
self.ssr_worker = Some(worker_path.into());
self
}
pub fn build(self) -> Arc<MizanState> {
Arc::new(MizanState {
app_state: self.app_state,
auth: self.auth,
cache: self.cache,
base_url: self.base_url,
ssr_worker: self.ssr_worker,
ssr_bridge: OnceLock::new(),
})
}
}

View File

@@ -0,0 +1,174 @@
//! WebSocket RPC transport. `@client(websocket=true)` functions declare
//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler
//! that dispatches call/fetch frames through the same `mizan-core` registry
//! the HTTP path uses. A call frame naming a non-websocket function is
//! rejected, so the transport boundary the IR declares is enforced.
//!
//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes:
//! → {"id": 1, "op": "call", "fn": "name", "args": {...}}
//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}}
//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]}
//! ← {"id": 2, "data": {fnName: result, ...}}
//! ← {"id": N, "error": {"code": ..., "message": ...}}
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::Response;
use futures_util::StreamExt;
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS,
};
use serde_json::{json, Map, Value};
use std::sync::Arc;
use crate::state::MizanState;
/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection.
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<MizanState>>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: Arc<MizanState>) {
while let Some(Ok(msg)) = socket.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue,
};
let reply = handle_frame(&state, &text).await;
if socket
.send(Message::Text(reply.to_string()))
.await
.is_err()
{
break;
}
}
}
async fn handle_frame(state: &MizanState, text: &str) -> Value {
let frame: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))),
};
let id = frame.get("id").cloned().unwrap_or(Value::Null);
let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call");
match op {
"call" => match dispatch_ws_call(state, &frame).await {
Ok(v) => with_id(id, v),
Err(e) => err_frame(id, &e),
},
"fetch" => match dispatch_ws_fetch(state, &frame).await {
Ok(v) => with_id(id, json!({ "data": v })),
Err(e) => err_frame(id, &e),
},
other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))),
}
}
async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
let fn_name = frame
.get("fn")
.and_then(|f| f.as_str())
.ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?;
let args = frame
.get("args")
.and_then(|a| a.as_object())
.cloned()
.unwrap_or_default();
let fn_spec =
lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
// The WS transport only carries functions that opted into it.
if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) {
return Err(MizanError::BadRequest(format!(
"function {fn_name:?} is not exposed over the WebSocket transport"
)));
}
enforce_anon_guard(fn_spec)?;
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let merges = compute_merges(fn_spec, &args, &result);
let mut out = Map::new();
out.insert("result".into(), result);
out.insert("invalidate".into(), Value::Array(invalidate));
if !merges.is_empty() {
out.insert(
"merge".into(),
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
);
}
Ok(Value::Object(out))
}
async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result<Value, MizanError> {
let ctx = frame
.get("context")
.and_then(|c| c.as_str())
.ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?;
if lookup_context(ctx).is_none() {
return Err(MizanError::NotFound(format!("context {ctx:?}")));
}
let params = frame
.get("params")
.and_then(|p| p.as_object())
.cloned()
.unwrap_or_default();
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(ctx))
.collect();
let mut bundle = Map::new();
for fn_spec in &members {
enforce_anon_guard(*fn_spec)?;
let mut args = Map::new();
for ip in fn_spec.input_params() {
if let Some(v) = params.get(ip.name) {
args.insert(ip.name.into(), v.clone());
}
}
let req = RequestHandle::from_dyn(state.app_state.as_ref());
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
bundle.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundle))
}
/// Enforce a function's auth guard for the WS transport. The WS upgrade
/// carries no per-frame identity in this baseline, so a guarded function is
/// rejected over WS — the same enforce-or-reject contract the HTTP path uses,
/// applied with an anonymous identity.
fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> {
let req = AuthRequirement::from_str_opt(fn_spec.auth());
mizan_core::enforce_auth(None, &req)
}
fn with_id(id: Value, mut body: Value) -> Value {
if let Some(obj) = body.as_object_mut() {
obj.insert("id".into(), id);
}
body
}
fn err_frame(id: Value, e: &MizanError) -> Value {
json!({
"id": id,
"error": { "code": e.code(), "message": e.message() },
})
}

View File

@@ -0,0 +1,422 @@
//! Runtime behavior tests for the axum adapter — the conformance ceiling that
//! the source-presence probes set the floor for. Each AFI-common HTTP cell is
//! driven end to end through the real router (`tower::ServiceExt::oneshot`,
//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs
//! against a real bound port.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
};
use mizan_axum::{router, MizanState};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use tower::ServiceExt;
// ─── Fixture: the functions these tests dispatch ────────────────────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Profile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Ok {
pub ok: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Secret {
pub flag: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct UploadEcho {
pub filename: String,
pub size: i64,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct SchemaOut {
pub fields: Vec<String>,
}
#[mizan::context("bprofile")]
pub struct BProfileCtx;
#[mizan::client(context = BProfileCtx)]
pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile {
Profile {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(affects = BProfileCtx)]
pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok {
let _ = (user_id, name);
Ok { ok: true }
}
#[mizan::client(auth = "staff")]
pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret {
Secret {
flag: "top-secret".into(),
}
}
#[mizan::client(websocket)]
pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok {
let _ = n;
Ok { ok: true }
}
#[mizan::client]
pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho {
let _ = user_id;
UploadEcho {
filename: avatar.filename.clone().unwrap_or_default(),
size: avatar.size() as i64,
}
}
#[mizan::client(form_name = "contact", form_role = "submit")]
pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok {
let _ = name;
Ok { ok: true }
}
#[mizan::client(form_name = "contact", form_role = "schema")]
pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut {
SchemaOut {
fields: vec!["name".into()],
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
fn stateless_app() -> axum::Router {
router(MizanState::builder().build())
}
async fn body_json(resp: axum::response::Response) -> Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response {
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.body(Body::from(json!({"fn": fn_name, "args": args}).to_string()))
.unwrap();
app.clone().oneshot(req).await.unwrap()
}
// ─── invalidate_header + invalidate_body + rpc_call ──────────────────────────
#[tokio::test]
async fn call_emits_invalidate_body_and_header() {
let app = stateless_app();
let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await;
assert_eq!(resp.status(), StatusCode::OK);
// The header is co-equal with the body channel: scoped to user_id=7.
let header = resp
.headers()
.get("X-Mizan-Invalidate")
.expect("X-Mizan-Invalidate present")
.to_str()
.unwrap()
.to_string();
assert_eq!(header, "bprofile;user_id=7");
assert_eq!(
resp.headers().get("cache-control").unwrap(),
"no-store"
);
let body = body_json(resp).await;
assert_eq!(body["result"], json!({"ok": true}));
// Body invalidate entry is the scoped object form.
assert_eq!(
body["invalidate"],
json!([{"context": "bprofile", "params": {"user_id": 7}}])
);
}
// ─── auth_enforcement ────────────────────────────────────────────────────────
#[tokio::test]
async fn auth_guard_rejects_anonymous_and_admits_staff() {
// No auth config + a staff-guarded fn → anonymous is rejected 401.
let app = stateless_app();
let resp = post_call(&app, "b_secret", json!({})).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
// With a JWT config + a staff token, the same call is admitted. Mint at
// the real clock so the token is unexpired when the handler verifies it.
let cfg = JwtConfig::new("beh-secret");
let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix());
let auth = AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["result"], json!({"flag": "top-secret"}));
}
#[tokio::test]
async fn auth_guard_forbids_non_staff_token() {
// A valid but non-staff token → 403 on a staff-guarded fn.
let cfg = JwtConfig::new("beh-secret");
let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix());
let auth = AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("POST")
.uri("/call/")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn invalid_token_is_rejected_not_downgraded() {
// A present-but-bad bearer rejects (401) even on an unguarded context —
// the INVALID-sentinel contract.
let auth = AuthConfig {
jwt: Some(JwtConfig::new("beh-secret")),
mwt_secret: None,
mwt_audience: "mizan".into(),
};
let app = router(MizanState::builder().auth(auth).build());
let req = Request::builder()
.method("GET")
.uri("/ctx/bprofile/?user_id=1")
.header("authorization", "Bearer not-a-real-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ─── origin_cache ────────────────────────────────────────────────────────────
#[tokio::test]
async fn context_fetch_uses_origin_cache() {
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into()));
let app = router(MizanState::builder().cache(cache).build());
// First fetch: MISS, populates the cache.
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
let first = body_json(resp).await;
assert_eq!(first["b_user_profile"]["user_id"], json!(3));
// Second fetch: HIT, served from cache.
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT");
let second = body_json(resp).await;
assert_eq!(first, second);
// A mutation scoped to user_id=3 purges that key → next fetch MISSes.
let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await;
let req = Request::builder()
.uri("/ctx/bprofile/?user_id=3")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS");
}
// ─── upload ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn multipart_upload_binds_into_input() {
let app = stateless_app();
let boundary = "----mizanbeh";
let file_bytes = b"PNGDATA-0123456789";
let body = format!(
"--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\
--{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\
--{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\
Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n",
b = boundary,
data = String::from_utf8_lossy(file_bytes),
);
let req = Request::builder()
.method("POST")
.uri("/call/")
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["result"]["filename"], json!("a.png"));
assert_eq!(body["result"]["size"], json!(file_bytes.len()));
}
// ─── edge_manifest + psr ─────────────────────────────────────────────────────
#[tokio::test]
async fn manifest_and_psr_descriptor() {
let app = stateless_app();
let req = Request::builder()
.uri("/manifest/")
.body(Body::empty())
.unwrap();
let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await;
// bprofile is user-scoped (user_id) → dynamic_cached.
assert_eq!(
manifest["contexts"]["bprofile"]["render_strategy"],
json!("dynamic_cached")
);
assert_eq!(
manifest["mutations"]["b_update_profile"]["affects"],
json!(["bprofile"])
);
// Per-context PSR descriptor.
let req = Request::builder()
.uri("/psr/bprofile/")
.body(Body::empty())
.unwrap();
let psr = body_json(app.oneshot(req).await.unwrap()).await;
assert_eq!(psr["render_strategy"], json!("dynamic_cached"));
}
// ─── shapes ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn shape_projection_endpoint() {
let app = stateless_app();
let req = Request::builder()
.uri("/shape/b_user_profile/")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
// Output type name is camelCased by the macro (`b_user_profile` →
// `bUserProfile`), suffixed `Output`.
assert_eq!(body["type"], json!("bUserProfileOutput"));
let fields = body["fields"].as_array().unwrap();
assert!(fields.contains(&json!("user_id")));
assert!(fields.contains(&json!("name")));
}
// ─── forms ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn forms_schema_and_submit_routes() {
let app = stateless_app();
let req = Request::builder()
.method("POST")
.uri("/form/contact/schema/")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["fields"], json!(["name"]));
let req = Request::builder()
.method("POST")
.uri("/form/contact/submit/")
.header("content-type", "application/json")
.body(Body::from(json!({"name": "Ada"}).to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await, json!({"ok": true}));
}
// ─── websocket ───────────────────────────────────────────────────────────────
#[tokio::test]
async fn websocket_transport_dispatches_and_rejects_non_ws_fn() {
use tokio_tungstenite::tungstenite::Message;
// Bind a real socket — the WS upgrade needs an actual connection.
let app = stateless_app();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let url = format!("ws://{addr}/ws/");
let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap();
// A websocket-declared fn dispatches.
use futures_util::{SinkExt, StreamExt};
socket
.send(Message::Text(
json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(),
))
.await
.unwrap();
let reply = socket.next().await.unwrap().unwrap();
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
assert_eq!(v["id"], json!(1));
assert_eq!(v["result"], json!({"ok": true}));
// A non-websocket fn over WS is rejected (transport boundary enforced).
socket
.send(Message::Text(
json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}})
.to_string(),
))
.await
.unwrap();
let reply = socket.next().await.unwrap().unwrap();
let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap();
assert_eq!(v["id"], json!(2));
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("WebSocket transport"));
server.abort();
}

View File

@@ -558,6 +558,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1228,6 +1229,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1545,7 +1555,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"jni-sys",
"log",
"thiserror 1.0.69",
"walkdir",
@@ -1554,37 +1564,15 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.1"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "js-sys"
version = "0.3.98"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
@@ -1682,9 +1670,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"libc",
]
@@ -1788,10 +1776,13 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"hmac",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -1808,10 +1799,12 @@ dependencies = [
name = "mizan-tauri"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"mizan-core",
"serde",
"serde_json",
"tauri",
"tokio",
]
[[package]]
@@ -1842,7 +1835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"jni-sys",
"log",
"ndk-sys",
"num_enum",
@@ -1856,7 +1849,7 @@ version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys 0.3.1",
"jni-sys",
]
[[package]]
@@ -2467,9 +2460,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -2908,6 +2901,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3360,9 +3359,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3785,9 +3796,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
@@ -3798,9 +3809,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.71"
version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -3808,9 +3819,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3818,9 +3829,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3831,9 +3842,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
@@ -3887,9 +3898,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.98"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [
"js-sys",
"wasm-bindgen",

View File

@@ -10,3 +10,8 @@ mizan-core = { path = "../../cores/mizan-rust" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
tokio = { version = "1", features = ["rt", "macros"] }
base64 = "0.22"

View File

@@ -1,4 +1,5 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//!
@@ -9,79 +10,137 @@
//! .expect("error while running tauri application");
//! ```
//!
//! The plugin exposes a single command `mizan_invoke` (full Tauri name
//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport`
//! sends call/fetch envelopes to it; the dispatch routes through
//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same
//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum)
//! consumes. There is no per-function tauri::command; the registry IS
//! the dispatch table.
//! The plugin exposes commands reachable from the JS-side
//! `@mizan/tauri-transport`:
//!
//! Wire envelope:
//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/
//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/).
//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a
//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of
//! the HTTP WebSocket — there are no sockets in a desktop shell, so a
//! Tauri `Channel<T>` carries the push stream instead.
//!
//! Wire envelope (the `mizan_invoke` payload's `envelope` field):
//!
//! ```json
//! { "op": "call", "fn": "list_sessions", "args": {} }
//! { "op": "fetch", "context": "session", "params": {} }
//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? }
//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? }
//! { "op": "shape", "fn": "user_profile" }
//! { "op": "form", "form": "contact", "role": "submit", "args": {} }
//! ```
//!
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//! Response shapes mirror the HTTP adapter:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! * `shape` → `{ type, fields }`
//! * `form` → the form function's result
//!
//! Error responses come back as the `Err` variant of the Tauri command's
//! `Result`, which Tauri serializes into the JS-side `Promise.reject`.
//! The TS-side transport re-wraps it into a `MizanError` so consumers
//! see one error surface regardless of transport.
//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token`
//! equivalent) or a `Bearer <jwt>`; it is resolved through the shared
//! `authenticate` and enforced against each function's `auth=` requirement.
//! There is no header channel over IPC, so the token rides the envelope.
//!
//! Errors come back as the `Err` variant of the command's `Result`, which
//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it
//! into a `MizanError`.
mod ssr;
pub use ssr::{ssr_render, MizanSsr};
use mizan_core::{
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context,
lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator,
FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::ipc::Channel;
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
Manager, Runtime,
};
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`
/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch
/// command is reachable from JS as `plugin:mizan|mizan_invoke`.
/// The Mizan config Tauri manages: auth (token → identity) + the origin cache.
/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the
/// dispatch commands read it from managed state.
pub struct MizanTauriConfig {
pub auth: AuthConfig,
pub cache: CacheOrchestrator,
}
impl Default for MizanTauriConfig {
fn default() -> Self {
Self {
auth: AuthConfig::new(),
cache: CacheOrchestrator::disabled(),
}
}
}
/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`.
/// Registers a default (auth-off, cache-disabled) config if the consumer
/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke`
/// and `plugin:mizan|mizan_subscribe`.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("mizan")
.invoke_handler(tauri::generate_handler![mizan_invoke])
.invoke_handler(tauri::generate_handler![
mizan_invoke,
mizan_subscribe,
ssr::ssr_render
])
.setup(|app, _api| {
if app.try_state::<MizanTauriConfig>().is_none() {
app.manage(MizanTauriConfig::default());
}
Ok(())
})
.build()
}
// === Wire envelope ===
/// One Mizan request. The JS-side transport sends `{ envelope: ... }`;
/// Tauri's serde deserializer pulls this struct out of the `envelope`
/// field of the invoke payload.
/// One Mizan request. Tauri's serde deserializer pulls this out of the
/// `envelope` field of the invoke payload.
#[derive(Debug, Deserialize)]
#[serde(tag = "op")]
pub enum Envelope {
#[serde(rename = "call")]
Call {
/// Wire-level function name — registered name on the Rust side.
#[serde(rename = "fn")]
function_name: String,
#[serde(default)]
args: Map<String, Value>,
/// Optional auth token (MWT, or `Bearer <jwt>`) — the IPC analogue of
/// the HTTP `X-Mizan-Token` / `Authorization` headers.
#[serde(default)]
token: Option<String>,
},
#[serde(rename = "fetch")]
Fetch {
context: String,
#[serde(default)]
params: Map<String, Value>,
#[serde(default)]
token: Option<String>,
},
#[serde(rename = "shape")]
Shape {
#[serde(rename = "fn")]
function_name: String,
},
#[serde(rename = "form")]
Form {
form: String,
role: String,
#[serde(default)]
args: Value,
},
}
/// Error payload returned to the frontend. Mirrors the HTTP adapter's
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
/// this and constructs a `MizanError`.
/// `{"code", "message", "details?"}` shape.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
@@ -105,110 +164,336 @@ impl From<MizanError> for ErrorPayload {
}
}
// === Dispatch ===
// === Auth ===
/// The single Mizan dispatch command. Registered on the plugin's invoke
/// handler — the consumer never wires it directly.
///
/// `app: AppHandle` is auto-injected by Tauri; the function body borrows
/// it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for app-managed state or event
/// emission. Stateless functions ignore the handle.
/// Resolve identity from an envelope `token`. An MWT is tried first (raw
/// token), then a `Bearer <jwt>`. A present-but-invalid token rejects (the
/// `INVALID`-sentinel contract); absent → anonymous.
fn identity_from_token(
token: Option<&str>,
config: &MizanTauriConfig,
) -> Result<Option<Identity>, MizanError> {
let (mwt, bearer) = match token {
Some(t) if t.starts_with("Bearer ") => (None, Some(t)),
Some(t) => (Some(t), None),
None => (None, None),
};
match authenticate(mwt, bearer, &config.auth, now_unix()) {
AuthOutcome::Authenticated(id) => Ok(Some(id)),
AuthOutcome::Anonymous => Ok(None),
AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())),
}
}
fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> {
enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth()))
}
// === Dispatch commands ===
/// The single Mizan request/response command. Tauri auto-injects `app`; the
/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can
/// `req.downcast::<tauri::AppHandle>()` for managed state or event emission.
#[tauri::command]
async fn mizan_invoke<R: Runtime>(
app: tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, ErrorPayload> {
dispatch(&app, envelope).await.map_err(ErrorPayload::from)
}
/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON
/// response (or a `MizanError`). This is the programmatic entry point the
/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior
/// tests) can drive the Mizan protocol without the IPC serialization layer.
pub async fn dispatch<R: Runtime>(
app: &tauri::AppHandle<R>,
envelope: Envelope,
) -> Result<Value, MizanError> {
// Read the managed config (lifetime-bound to `app`, which outlives this
// dispatch); fall back to a default if none was registered. The `State`
// guard is held across the awaits below.
let managed = app.try_state::<MizanTauriConfig>();
let default;
let cfg: &MizanTauriConfig = match managed.as_ref() {
Some(state) => state.inner(),
None => {
default = MizanTauriConfig::default();
&default
}
};
match envelope {
Envelope::Call {
function_name,
args,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
token,
} => handle_call(app, cfg, &function_name, args, token.as_deref()).await,
Envelope::Fetch {
context,
params,
token,
} => handle_fetch(app, cfg, &context, params, token.as_deref()).await,
Envelope::Shape { function_name } => handle_shape(&function_name),
Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await,
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
fn_name: &str,
args: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
let fn_spec = lookup_function(fn_name).ok_or_else(|| {
ErrorPayload::from(MizanError::NotFound(format!(
"function {fn_name:?} not registered"
)))
})?;
mut args: Map<String, Value>,
token: Option<&str>,
) -> Result<Value, MizanError> {
let identity = identity_from_token(token, cfg)?;
let fn_spec = lookup_function(fn_name)
.ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
guard(fn_spec, identity.as_ref())?;
// Bind any file parts the envelope carries into the call args (see
// `bind_uploads`).
bind_uploads(fn_spec, &mut args)?;
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
let mut payload = json!({
"result": result,
"invalidate": invalidate,
});
if let Some(merge) = merge_payload {
payload
.as_object_mut()
.expect("payload is a JSON object")
.insert("merge".into(), Value::Array(merge));
// Purge the origin cache for everything this mutation invalidated.
if !targets.is_empty() {
let uid = identity.as_ref().map(|i| i.user_id.clone());
cfg.cache.purge(&targets, uid.as_deref());
}
let mut payload = json!({ "result": result, "invalidate": invalidate });
if !merges.is_empty() {
payload.as_object_mut().unwrap().insert(
"merge".into(),
Value::Array(merges.iter().map(MergeEntry::to_json).collect()),
);
}
Ok(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
context_name: &str,
params: Map<String, Value>,
) -> Result<Value, ErrorPayload> {
if lookup_context(context_name).is_none() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
"context {context_name:?} not registered"
))));
}
token: Option<&str>,
) -> Result<Value, MizanError> {
let identity = identity_from_token(token, cfg)?;
if lookup_context(context_name).is_none() {
return Err(MizanError::NotFound(format!(
"context {context_name:?} not registered"
)));
}
let members: Vec<&dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(context_name))
.collect();
if members.is_empty() {
return Err(ErrorPayload::from(MizanError::NotFound(format!(
return Err(MizanError::NotFound(format!(
"context {context_name:?} has no registered members"
))));
)));
}
// Origin cache: a desktop shell still benefits from memoizing a context
// bundle by (context, params, user). Key the params as JSON values.
let cache_params: std::collections::BTreeMap<String, Value> = params
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let uid = identity.as_ref().map(|i| i.user_id.clone());
if let Some(cached) = cfg
.cache
.get(context_name, &cache_params, uid.as_deref(), 0)
{
if let Ok(v) = serde_json::from_slice::<Value>(&cached) {
return Ok(v);
}
}
let mut bundled = Map::new();
for fn_spec in &members {
guard(*fn_spec, identity.as_ref())?;
let args = filter_args(*fn_spec, &params);
let req = RequestHandle::new(app);
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ErrorPayload::from)?;
let result = fn_spec.dispatch(req, Value::Object(args)).await?;
bundled.insert(fn_spec.name().to_string(), result);
}
Ok(Value::Object(bundled))
let body = Value::Object(bundled);
if cfg.cache.enabled() {
let bytes = serde_json::to_vec(&body).unwrap();
cfg.cache
.put(context_name, &cache_params, bytes, uid.as_deref(), 0);
}
Ok(body)
}
/// Filter the envelope's params down to keys this function declares as
/// input. The HTTP/axum adapter coerces string-typed query params to
/// JSON primitives in the equivalent step; the Tauri arg channel already
/// carries typed JSON, so the filter is sufficient on its own.
/// `shape` op — the typed query projection for a function's output, derived by
/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding).
fn handle_shape(fn_name: &str) -> Result<Value, MizanError> {
let proj = shapes::project_function_output(fn_name)
.ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?;
Ok(projection_to_json(&proj))
}
fn projection_to_json(proj: &shapes::QueryProjection) -> Value {
let mut fields = Vec::new();
for f in &proj.fields {
match f {
shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())),
shapes::ShapeField::Nested(n, sub) => {
fields.push(json!({ n.clone(): projection_to_json(sub) }));
}
}
}
json!({ "type": proj.type_name, "fields": fields })
}
/// `form` op — dispatch a form's schema/validate/submit function (the IPC
/// Forms binding). `form_validate` / `form_submit` map to the registered
/// function whose `(form_name, form_role)` matches.
async fn handle_form<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
role: &str,
args: Value,
) -> Result<Value, MizanError> {
match role {
"schema" => form_schema(app, form_name).await,
"validate" => form_validate(app, form_name, args).await,
"submit" => form_submit(app, form_name, args).await,
other => Err(MizanError::BadRequest(format!(
"unknown form role {other:?} (expected schema|validate|submit)"
))),
}
}
fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role))
}
async fn dispatch_form_role<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
role: &str,
args: Value,
) -> Result<Value, MizanError> {
let fn_spec = lookup_form_fn(form_name, role)
.ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?;
let args_value = match args {
Value::Object(_) | Value::Null => args,
other => json!({ "data": other }),
};
let req = RequestHandle::new(app);
fn_spec.dispatch(req, args_value).await
}
async fn form_schema<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "schema", Value::Null).await
}
async fn form_validate<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
args: Value,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "validate", args).await
}
async fn form_submit<R: Runtime>(
app: &tauri::AppHandle<R>,
form_name: &str,
args: Value,
) -> Result<Value, MizanError> {
dispatch_form_role(app, form_name, "submit", args).await
}
// === WebSocket-equivalent: IPC subscription channel ===
/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape.
#[derive(Clone, Serialize)]
pub struct SubscriptionFrame {
pub result: Value,
pub invalidate: Vec<Value>,
}
/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]`
/// function. A desktop shell has no WebSocket; a Tauri `Channel<T>` carries
/// the push stream instead — the IPC transport's co-equal of the HTTP
/// WebSocket. The initial dispatch result is emitted immediately on the
/// channel; subsequent server-side pushes use the same `on_event` channel.
#[tauri::command]
async fn mizan_subscribe<R: Runtime>(
app: tauri::AppHandle<R>,
function_name: String,
args: Map<String, Value>,
on_event: Channel<SubscriptionFrame>,
) -> Result<(), ErrorPayload> {
subscribe(&app, &function_name, args, on_event)
.await
.map_err(ErrorPayload::from)
}
/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on
/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command
/// wraps — exposed for embedders and behavior tests.
pub async fn subscribe<R: Runtime>(
app: &tauri::AppHandle<R>,
function_name: &str,
args: Map<String, Value>,
on_event: Channel<SubscriptionFrame>,
) -> Result<(), MizanError> {
let fn_spec = lookup_function(function_name)
.ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?;
if fn_spec.private() {
return Err(MizanError::Forbidden("Function is not client-callable".into()));
}
// Only `#[mizan(websocket)]` functions are exposed over the subscription
// channel — the same transport boundary the HTTP WebSocket enforces.
if !matches!(
fn_spec.transport(),
mizan_core::Transport::Websocket | mizan_core::Transport::Both
) {
return Err(MizanError::BadRequest(format!(
"function {function_name:?} is not exposed over the subscription transport"
)));
}
let req = RequestHandle::new(app);
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let invalidate = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
on_event
.send(SubscriptionFrame { result, invalidate })
.map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?;
Ok(())
}
// === Helpers ===
/// Filter the envelope's params down to keys this function declares as input.
fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for ip in fn_spec.input_params() {
@@ -218,3 +503,45 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map<String, Value>) -> Map<S
}
out
}
/// Bind file parts carried in the IPC envelope into the call args.
///
/// Over IPC there is no `multipart/form-data`; a file rides the envelope as a
/// JSON object `{filename, content_type, data_b64}` (the JS transport
/// base64-packs the bytes). That object is exactly what `mizan_core::Upload`
/// deserializes, so for a single file the arg is already in place. This binder
/// performs the one transform IPC needs: a top-level `_files` map
/// (`{ field: <file-obj> | [<file-obj>, ...] }`) is merged into the args under
/// each field name, mirroring how the HTTP adapter binds multipart parts. It
/// also validates that anything presenting as a file carries `data_b64`,
/// surfacing a clear error before the typed `Upload` deserialize runs.
fn bind_uploads(
fn_spec: &dyn FunctionSpec,
args: &mut Map<String, Value>,
) -> Result<(), MizanError> {
if let Some(Value::Object(files)) = args.remove("_files") {
for (field, parts) in files {
args.insert(field, parts);
}
}
// The set of param names this function declares — only validate args that
// could land in a typed field.
let declared: std::collections::HashSet<&str> =
fn_spec.input_params().iter().map(|p| p.name).collect();
for (name, value) in args.iter() {
if !declared.contains(name.as_str()) {
continue;
}
if let Value::Object(obj) = value {
let looks_like_file =
obj.contains_key("filename") || obj.contains_key("content_type");
if looks_like_file && !obj.contains_key("data_b64") {
return Err(MizanError::BadRequest(format!(
"upload field {name:?} is missing `data_b64` (the base64 file bytes)"
)));
}
}
}
Ok(())
}

View File

@@ -0,0 +1,67 @@
//! SSR over the IPC transport — drive the Bun renderer through the shared
//! `mizan_core::SsrBridge` (the same newline-delimited JSON-RPC protocol the
//! Django/FastAPI/axum adapters use). A desktop shell renders React the same
//! way the server does: spawn the Bun worker once, drive `renderToString`
//! through it, keep it alive.
//!
//! Exposed as a Tauri command + a managed `MizanSsr` holding the bridge:
//!
//! invoke('plugin:mizan|ssr_render', { file: '/abs/X.tsx', props: {...} })
//! → { html: "<div>...</div>" }
use mizan_core::SsrBridge;
use serde::Serialize;
use serde_json::Value;
use std::sync::Arc;
use tauri::{Manager, Runtime};
use crate::ErrorPayload;
/// Managed SSR state — holds the persistent Bun bridge. Register it with
/// `app.manage(MizanSsr::new("path/to/worker.tsx"))` to enable `ssr_render`.
pub struct MizanSsr {
bridge: Arc<SsrBridge>,
}
impl MizanSsr {
/// Build an SSR state that launches `bun run <worker_path>` on first render.
pub fn new(worker_path: impl Into<String>) -> Self {
Self {
bridge: Arc::new(SsrBridge::bun(worker_path)),
}
}
/// The shared `mizan_core` SSR bridge backing this state — the persistent
/// Bun subprocess that runs `renderToString` over JSON-RPC. Exposed so a
/// consumer can render directly (e.g. PSR re-render on mutation) without
/// going through the `ssr_render` IPC command.
pub fn ssr_bridge(&self) -> &SsrBridge {
&self.bridge
}
}
#[derive(Serialize)]
pub struct SsrResult {
pub html: String,
}
/// `ssr_render` — render a component file to HTML via the Bun SSR worker.
/// Requires a managed `MizanSsr` (else returns a NOT_IMPLEMENTED error).
#[tauri::command]
pub async fn ssr_render<R: Runtime>(
app: tauri::AppHandle<R>,
file: String,
props: Option<Value>,
) -> Result<SsrResult, ErrorPayload> {
let state = app.try_state::<MizanSsr>().ok_or_else(|| {
ErrorPayload::from(mizan_core::MizanError::NotImplementedYet(
"no SSR worker configured (app.manage(MizanSsr::new(...)))".into(),
))
})?;
let bridge = state.bridge.clone();
let props = props.unwrap_or_else(|| serde_json::json!({}));
let html = bridge
.render(&file, props)
.map_err(|e| ErrorPayload::from(mizan_core::MizanError::InternalError(e.to_string())))?;
Ok(SsrResult { html })
}

View File

@@ -0,0 +1,370 @@
//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling
//! over the source-presence probes. Each IPC-applicable cell is driven through
//! the real dispatch path against a mock Tauri `AppHandle`
//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel
//! frames. The IPC serialization boundary is exercised by Tauri's own
//! `get_ipc_response` machinery in integration; here we drive `dispatch` /
//! `subscribe` (the programmatic entry points the commands wrap) so the
//! protocol logic — auth, cache, upload binding, shapes, forms, subscription —
//! is asserted directly.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
};
use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::sync::{Arc, Mutex};
use tauri::ipc::Channel;
use tauri::test::mock_app;
use tauri::{AppHandle, Manager};
// ─── Fixture functions (auto-registered via linkme at link time) ────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TProfile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TOk {
pub ok: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TSecret {
pub flag: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TUploadEcho {
pub filename: String,
pub size: i64,
}
#[mizan::context("tprofile")]
pub struct TProfileCtx;
#[mizan::client(context = TProfileCtx)]
pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile {
TProfile {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(affects = TProfileCtx)]
pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk {
let _ = (user_id, name);
TOk { ok: true }
}
#[mizan::client(auth = "staff")]
pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret {
TSecret {
flag: "ipc-secret".into(),
}
}
#[mizan::client(websocket)]
pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk {
let _ = room;
TOk { ok: true }
}
#[mizan::client]
pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho {
let _ = user_id;
TUploadEcho {
filename: avatar.filename.clone().unwrap_or_default(),
size: avatar.size() as i64,
}
}
#[mizan::client(form_name = "tcontact", form_role = "submit")]
pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk {
let _ = name;
TOk { ok: true }
}
// ─── Harness ────────────────────────────────────────────────────────────────
/// Build a mock app with the given Mizan config managed.
fn app_with(config: MizanTauriConfig) -> AppHandle<tauri::test::MockRuntime> {
let app = mock_app();
let handle = app.handle().clone();
handle.manage(config);
// Leak the app so its `AppHandle` stays valid for the test body; the
// process tears down at test end.
std::mem::forget(app);
handle
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
// ─── rpc_call + invalidate_body ──────────────────────────────────────────────
#[test]
fn call_returns_result_and_invalidate() {
let handle = app_with(MizanTauriConfig::default());
rt().block_on(async {
let env = Envelope::Call {
function_name: "t_update_profile".into(),
args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]),
token: None,
};
let resp = dispatch(&handle, env).await.unwrap();
assert_eq!(resp["result"], json!({"ok": true}));
// IPC carries invalidation in the envelope (no header channel).
assert_eq!(
resp["invalidate"],
json!([{"context": "tprofile", "params": {"user_id": 7}}])
);
});
}
// ─── auth_enforcement ────────────────────────────────────────────────────────
#[test]
fn auth_guard_over_ipc() {
rt().block_on(async {
// No auth config → anonymous → staff-guarded fn rejected.
let handle = app_with(MizanTauriConfig::default());
let err = dispatch(
&handle,
Envelope::Call {
function_name: "t_secret".into(),
args: Map::new(),
token: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
// Staff JWT on the envelope token → admitted.
let cfg = JwtConfig::new("ipc-secret");
let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix());
let config = MizanTauriConfig {
auth: AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
},
cache: CacheOrchestrator::disabled(),
};
let handle = app_with(config);
let resp = dispatch(
&handle,
Envelope::Call {
function_name: "t_secret".into(),
args: Map::new(),
token: Some(format!("Bearer {token}")),
},
)
.await
.unwrap();
assert_eq!(resp["result"]["flag"], json!("ipc-secret"));
});
}
#[test]
fn invalid_token_rejected_over_ipc() {
rt().block_on(async {
let config = MizanTauriConfig {
auth: AuthConfig {
jwt: Some(JwtConfig::new("ipc-secret")),
mwt_secret: None,
mwt_audience: "mizan".into(),
},
cache: CacheOrchestrator::disabled(),
};
let handle = app_with(config);
let err = dispatch(
&handle,
Envelope::Fetch {
context: "tprofile".into(),
params: obj(&[("user_id", json!(1))]),
token: Some("Bearer garbage".into()),
},
)
.await
.unwrap_err();
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
});
}
// ─── origin_cache ────────────────────────────────────────────────────────────
#[test]
fn fetch_uses_origin_cache() {
rt().block_on(async {
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into()));
let config = MizanTauriConfig {
auth: AuthConfig::new(),
cache,
};
let handle = app_with(config);
let fetch = || Envelope::Fetch {
context: "tprofile".into(),
params: obj(&[("user_id", json!(3))]),
token: None,
};
let first = dispatch(&handle, fetch()).await.unwrap();
assert_eq!(first["t_user_profile"]["user_id"], json!(3));
// The cache now holds the bundle — confirm a key exists under the
// context prefix (proves the put happened).
let key = mizan::derive_cache_key(
"ipc-cache-secret",
"tprofile",
&std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]),
None,
0,
);
assert!(backend.get(&key).is_some(), "fetch populated the origin cache");
// Second fetch returns the same bundle (served from cache).
let second = dispatch(&handle, fetch()).await.unwrap();
assert_eq!(first, second);
// A scoped mutation purges the key.
let _ = dispatch(
&handle,
Envelope::Call {
function_name: "t_update_profile".into(),
args: obj(&[("user_id", json!(3)), ("name", json!("New"))]),
token: None,
},
)
.await
.unwrap();
assert!(backend.get(&key).is_none(), "mutation purged the cache key");
});
}
// ─── upload ──────────────────────────────────────────────────────────────────
#[test]
fn upload_binds_from_envelope() {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let data = b"IPC-FILE-BYTES";
let file = json!({
"filename": "a.png",
"content_type": "image/png",
"data_b64": STANDARD.encode(data),
});
let resp = dispatch(
&handle,
Envelope::Call {
function_name: "t_set_avatar".into(),
args: obj(&[("user_id", json!(9)), ("avatar", file)]),
token: None,
},
)
.await
.unwrap();
assert_eq!(resp["result"]["filename"], json!("a.png"));
assert_eq!(resp["result"]["size"], json!(data.len()));
});
}
// ─── shapes ──────────────────────────────────────────────────────────────────
#[test]
fn shape_op_projects_output() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let resp = dispatch(
&handle,
Envelope::Shape {
function_name: "t_user_profile".into(),
},
)
.await
.unwrap();
assert_eq!(resp["type"], json!("tUserProfileOutput"));
let fields = resp["fields"].as_array().unwrap();
assert!(fields.contains(&json!("user_id")));
assert!(fields.contains(&json!("name")));
});
}
// ─── forms ───────────────────────────────────────────────────────────────────
#[test]
fn form_submit_op() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let resp = dispatch(
&handle,
Envelope::Form {
form: "tcontact".into(),
role: "submit".into(),
args: json!({"name": "Ada"}),
},
)
.await
.unwrap();
assert_eq!(resp, json!({"ok": true}));
});
}
// ─── websocket-equivalent: subscription channel ──────────────────────────────
#[test]
fn subscription_pushes_frame_and_rejects_non_ws_fn() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
// A websocket-declared fn pushes a frame on the channel.
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let channel: Channel<SubscriptionFrame> = Channel::new(move |body| {
// The channel serializes the SubscriptionFrame to JSON; read it
// back as a generic Value.
let v: Value = body.deserialize().unwrap_or(Value::Null);
sink.lock().unwrap().push(v);
Ok(())
});
subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel)
.await
.unwrap();
let frames = captured.lock().unwrap();
assert_eq!(frames.len(), 1, "subscription pushed exactly one frame");
assert_eq!(frames[0]["result"], json!({"ok": true}));
// A non-websocket fn over the subscription transport is rejected.
let reject_channel: Channel<SubscriptionFrame> = Channel::new(|_| Ok(()));
let err = subscribe(
&handle,
"t_user_profile",
obj(&[("user_id", json!(1))]),
reject_channel,
)
.await
.unwrap_err();
assert!(err.message().contains("subscription transport"));
});
}
// ─── helpers ──────────────────────────────────────────────────────────────────
fn obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}

View File

@@ -5,15 +5,31 @@
"": {
"name": "@mizan/ts",
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "latest",
"react": "^19",
"react-dom": "^19",
},
},
},
"packages": {
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@@ -8,7 +8,11 @@
"test": "bun test"
},
"devDependencies": {
"bun-types": "latest"
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "latest",
"react": "^19",
"react-dom": "^19"
},
"license": "Elastic-2.0"
}

View File

@@ -13,7 +13,7 @@
* }
*/
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types'
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types'
import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
@@ -21,6 +21,12 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
return ctx
}
function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined {
if (!merge) return undefined
const items = Array.isArray(merge) ? merge : [merge]
return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m))
}
/**
* Normalize the public auth option into the stored requirement.
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
@@ -65,6 +71,36 @@ function extractParams(fn: Function): ParamDef[] {
})
}
function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry {
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
return {
name,
fn: fn as any,
context,
affects,
merge: normalizeMerge(options.merge),
params: extractParams(fn),
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: normalizeAuth(options.auth),
websocket: options.websocket,
rev: options.rev,
cache: options.cache,
ir: options.ir,
form: options.form,
formName: options.formName,
formRole: options.formRole,
}
}
/**
* Function wrapper — registers a standalone function.
*
@@ -85,69 +121,19 @@ export function client<T extends (...args: any[]) => Promise<any>>(
*/
export function client(options: ClientOptions): MethodDecorator
export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any {
export function client(optionsOrFn: ClientOptions, fn?: Function): any {
// Function wrapper form: client(options, fn)
if (fn && typeof fn === 'function') {
const options = optionsOrFn as ClientOptions
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const name = fn.name || 'anonymous'
const params = extractParams(fn)
const isView = false // Determined at call time for function wrappers
const entry: RegistryEntry = {
name,
fn: fn as any,
context,
affects,
params,
private: options.private ?? false,
viewPath: isView,
route: options.route,
methods: options.methods,
auth: normalizeAuth(options.auth),
rev: options.rev,
cache: options.cache,
}
register(entry)
register(buildEntry(options, name, fn))
return fn
}
// Decorator form: @client(options)
const options = optionsOrFn as ClientOptions
return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
const context = resolveContext(options.context)
const affects = normalizeAffects(options.affects)
if (context && affects) {
throw new Error('context and affects are mutually exclusive')
}
const params = extractParams(originalMethod)
const entry: RegistryEntry = {
name: propertyKey,
fn: originalMethod,
context,
affects,
params,
private: options.private ?? false,
viewPath: false,
route: options.route,
methods: options.methods,
auth: normalizeAuth(options.auth),
rev: options.rev,
cache: options.cache,
}
register(entry)
register(buildEntry(options, propertyKey, descriptor.value))
return descriptor
}
}

View File

@@ -10,6 +10,7 @@ import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
import { ANONYMOUS, type Identity } from './identity'
import type { AuthRequirement } from './types'
import { UploadedFile, bindUploads } from './upload'
let _cacheSecret: string | null = null
@@ -186,9 +187,10 @@ export async function handleContextFetch(
}
/**
* Handle POST /api/mizan/call/
* Handle POST /api/mizan/call/ — JSON body form.
*
* Dispatches to a named function. Returns result + invalidation.
* Dispatches to a named function. Returns result + invalidation. The multipart
* form (`handleMultipartCall`) binds file parts first, then routes here.
*/
export async function handleMutationCall(
fnName: string,
@@ -272,3 +274,63 @@ export async function handleMutationCall(
}
}
}
function badRequest(message: string): MizanResponse {
return {
status: 400,
body: { error: true, code: 'BAD_REQUEST', message },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/**
* Handle POST /api/mizan/call/ — multipart/form-data form.
*
* Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields
* arrive in a JSON `args` part, and each file part binds into the function's
* Upload-typed inputs (by field name) with declared `File(...)` constraints
* enforced. After binding, execution is identical to the JSON path.
*
* A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other
* parts that share an Upload field name are accepted too.
*/
export async function handleMultipartCall(
form: FormData,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> {
const fnRaw = form.get('fn')
if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field")
const fnName = fnRaw
const argsRaw = form.get('args')
let args: Record<string, any>
try {
args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {}
} catch {
return badRequest("Invalid JSON in 'args' field")
}
if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object")
const entry = getFunction(fnName)
if (entry) {
// Collect file parts by field name into UploadedFile buckets.
const files = new Map<string, UploadedFile[]>()
for (const key of new Set(form.keys())) {
if (key === 'fn' || key === 'args') continue
const bucket: UploadedFile[] = []
for (const part of form.getAll(key)) {
if (part instanceof Blob) {
const data = new Uint8Array(await part.arrayBuffer())
const filename = part instanceof File ? part.name : null
bucket.push(new UploadedFile(filename, part.type || null, data))
}
}
if (bucket.length > 0) files.set(key, bucket)
}
const err = bindUploads(entry, args, files)
if (err !== null) return badRequest(err)
}
return handleMutationCall(fnName, args, identity)
}

View File

@@ -0,0 +1,170 @@
/**
* Forms — schema / validate / submit, AFI-common.
*
* The binding is per-framework (Django Forms on Django; the project's form
* layer elsewhere). The TypeScript binding registers the same three `@client`
* functions `create_form_functions` registers, carrying the same
* `{ form, form_name, form_role }` meta the IR reads — `<name>-schema`,
* `<name>-validate`, and (when a submit handler is given) `<name>-submit`.
*
* schema → { fields: FieldSchema[] } — field definitions
* validate → { valid: boolean, errors: {field: [..]} } — per-field validation
* submit → the handler's return value — validate-then-handle
*
* A `FormField` declares its type/required/label and an optional `validate`
* predicate; `validateForm` runs every field's validator over the submitted
* data, mirroring Django's `form.is_valid()` / `form.errors`.
*/
import { client } from './decorator'
import type { FormRole } from './types'
export interface FormField {
name: string
type?: string
required?: boolean
label?: string
helpText?: string
choices?: Array<{ value: string; label: string }>
initial?: unknown
/**
* Field validator. Return an error message (or array of messages) to
* reject, or null/undefined to accept. Required-ness is enforced before
* the validator runs.
*/
validate?: (value: unknown, data: Record<string, unknown>) => string | string[] | null | undefined
}
export interface FormDefinition {
fields: FormField[]
}
export interface FieldSchema {
name: string
type: string
required: boolean
label: string
helpText: string
choices: Array<{ value: string; label: string }> | null
initial: unknown
}
export interface FormSchemaOutput {
fields: FieldSchema[]
}
export interface FormValidationOutput {
valid: boolean
errors: Record<string, string[]>
}
function titleize(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Build the field-definition schema for a form. Mirrors `build_form_schema`. */
export function formSchema(def: FormDefinition): FormSchemaOutput {
return {
fields: def.fields.map((f) => ({
name: f.name,
type: f.type ?? 'text',
required: f.required ?? true,
label: f.label ?? titleize(f.name),
helpText: f.helpText ?? '',
choices: f.choices ?? null,
initial: f.initial ?? null,
})),
}
}
/**
* Validate submitted `data` against a form. Required fields missing/empty and
* any field whose `validate` returns a message produce per-field errors.
* Mirrors Django's `is_valid()` → `{valid, errors}`.
*/
export function validateForm(def: FormDefinition, data: Record<string, unknown>): FormValidationOutput {
const errors: Record<string, string[]> = {}
for (const field of def.fields) {
const value = data[field.name]
const missing = value === undefined || value === null || value === ''
if ((field.required ?? true) && missing) {
errors[field.name] = ['This field is required.']
continue
}
if (missing) continue
const result = field.validate?.(value, data)
if (result !== null && result !== undefined) {
errors[field.name] = Array.isArray(result) ? result : [result]
}
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** A submit handler runs after validation passes. */
export type FormSubmitHandler = (data: Record<string, unknown>) => unknown | Promise<unknown>
export interface FormRegistration {
schema: string
validate: string
submit?: string
}
/**
* Register a form's schema / validate / submit functions with the registry.
*
* Equivalent to Python's `register_form`: three `@client` functions named
* `<name>-schema`, `<name>-validate`, `<name>-submit`, each carrying
* `{ form, formName, formRole }` so the IR emits `is-form`/`form-name`/
* `form-role`. `submit` is registered only when a handler is supplied.
*
* Returns the registered wire names.
*/
export function registerForm(
def: FormDefinition,
name: string,
options: { submit?: FormSubmitHandler } = {},
): FormRegistration {
const role = (r: FormRole) => ({ form: true, formName: name, formRole: r })
const schemaName = `${name}-schema`
const validateName = `${name}-validate`
const submitName = `${name}-submit`
// schema — returns the field definitions.
const schemaFn = async function () {
return formSchema(def)
}
Object.defineProperty(schemaFn, 'name', { value: schemaName })
client(role('schema'), schemaFn)
// validate — runs per-field validation over the submitted data.
const validateFn = async function (data: Record<string, unknown>) {
return validateForm(def, data)
}
Object.defineProperty(validateFn, 'name', { value: validateName })
client(role('validate'), validateFn)
const registration: FormRegistration = { schema: schemaName, validate: validateName }
// submit — validate, then hand off. Registered only with a handler.
if (options.submit) {
const handler = options.submit
const submitFn = async function (data: Record<string, unknown>) {
const validation = validateForm(def, data)
if (!validation.valid) {
return { ok: false, errors: validation.errors }
}
const result = await handler(data)
return { ok: true, result }
}
Object.defineProperty(submitFn, 'name', { value: submitName })
client(role('submit'), submitFn)
registration.submit = submitName
}
return registration
}

View File

@@ -1,23 +1,63 @@
export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement, FormRole } from './types'
export { ANONYMOUS } from './identity'
export type { Identity, AuthPredicate } from './identity'
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
export type { MwtPayload } from './token'
export {
decodeMwt,
decodeJwtBearer,
identityFromMwt,
signHs256,
signMwt,
mintMwt,
computePermissionKey,
signJwt,
createAccessToken,
createRefreshToken,
mintJwt,
} from './token'
export type { MwtPayload, MintUser, JwtConfig, JwtMintClaims, JwtTokenPair } from './token'
export { client } from './decorator'
export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry'
export { handleContextFetch, handleMutationCall } from './dispatch'
export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch'
export type { MizanResponse } from './dispatch'
export { UploadedFile, parseSize, validateUpload, bindUploads, uploadFields } from './upload'
export type { File as UploadFile } from './upload'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { handleSessionInit, sessionInitRoute, SESSION_INIT_PATH, SESSION_INIT_METHOD } from './session'
export { SSRBridge } from './ssr'
export type { SSRBridgeOptions, RenderResult } from './ssr'
export { handleWebSocketMessage, serveWebSocket } from './websocket'
export type { MizanWsFrame, MizanWsReply, WebSocketLike } from './websocket'
export { buildIr, snakeToCamel } from './ir'
export type { IrSchema, TypeShape, NamedType, StructField, Primitive, DefaultValue } from './ir'
export { Shape, project, projectRecord } from './shapes'
export type { QueryProjection } from './shapes'
export { registerForm, formSchema, validateForm } from './forms'
export type {
FormField,
FormDefinition,
FieldSchema,
FormSchemaOutput,
FormValidationOutput,
FormSubmitHandler,
FormRegistration,
} from './forms'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'

View File

@@ -0,0 +1,409 @@
/**
* KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
*
* The Python emitter is the spec; this is a second implementation under the
* same contract. `buildIr()` walks the registry, resolves the canonical named
* types each function references (`_collect_named_types`), and emits KDL the
* Rust codegen consumes. Any divergence is a bug here, not a contract change —
* `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`.
*/
import { getAllFunctions, getContextGroups, getFunction } from '../registry'
import type { RegistryEntry } from '../types'
import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types'
const INDENT = ' '
// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ────────────────────────────
function kdlString(s: string): string {
const escaped = s
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
return `"${escaped}"`
}
function kdlBool(b: boolean): string {
return b ? '#true' : '#false'
}
function kdlDefault(v: DefaultValue): string {
switch (v.kind) {
case 'null':
return '#null'
case 'boolean':
return kdlBool(v.value)
case 'integer':
return String(v.value)
case 'number':
// Match Python's repr(float): whole-number floats render as "1.0".
return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value)
case 'string':
return kdlString(v.value)
}
}
/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */
export function snakeToCamel(name: string): string {
const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_')
return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('')
}
function primitiveName(p: Primitive): string {
return p
}
// ─── Emitter ──────────────────────────────────────────────────────────────
class Emitter {
lines: string[] = []
private prefix(indent: number): string {
return INDENT.repeat(indent)
}
leaf(indent: number, ...parts: string[]): void {
this.lines.push(this.prefix(indent) + parts.join(' '))
}
open(indent: number, ...parts: string[]): void {
this.lines.push(this.prefix(indent) + parts.join(' ') + ' {')
}
close(indent: number): void {
this.lines.push(this.prefix(indent) + '}')
}
blank(): void {
this.lines.push('')
}
emitTypeChild(indent: number, shape: TypeShape): void {
switch (shape.kind) {
case 'primitive':
this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive)))
return
case 'ref':
this.leaf(indent, 'ref', kdlString(shape.name))
return
case 'list':
this.open(indent, 'list')
this.emitTypeChild(indent + 1, shape.inner)
this.close(indent)
return
case 'optional':
this.open(indent, 'optional')
this.emitTypeChild(indent + 1, shape.inner)
this.close(indent)
return
case 'enum':
this.leaf(indent, 'enum', ...shape.variants.map(kdlString))
return
case 'union':
this.open(indent, 'union')
for (const b of shape.branches) this.emitTypeChild(indent + 1, b)
this.close(indent)
return
case 'upload':
this.emitUpload(indent, shape)
return
}
}
private emitUpload(indent: number, shape: Extract<TypeShape, { kind: 'upload' }>): void {
const props: string[] = []
if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`)
if (shape.contentTypes && shape.contentTypes.length > 0) {
this.open(indent, 'upload', ...props)
for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct))
this.close(indent)
} else {
this.leaf(indent, 'upload', ...props)
}
}
emitNamedType(indent: number, name: string, body: NamedType): void {
this.open(indent, 'type', kdlString(name))
if (body.kind === 'struct') {
this.open(indent + 1, 'struct')
for (const field of body.fields) this.emitStructField(indent + 2, field)
this.close(indent + 1)
} else if (body.kind === 'alias') {
this.open(indent + 1, 'alias')
this.emitTypeChild(indent + 2, body.inner)
this.close(indent + 1)
} else {
this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString))
}
this.close(indent)
}
emitStructField(indent: number, field: StructField): void {
const header: string[] = ['field', kdlString(field.name)]
if (!field.required) {
header.push(`required=${kdlBool(false)}`)
if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`)
}
this.open(indent, ...header)
this.emitTypeChild(indent + 1, field.shape)
this.close(indent)
}
intoString(): string {
const lines = [...this.lines]
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
return lines.join('\n') + '\n'
}
}
// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ─────────────
/** Strip Optional[T] → [inner, isOptional]. */
function stripOptional(shape: TypeShape): [TypeShape, boolean] {
if (shape.kind === 'optional') return [shape.inner, true]
return [shape, false]
}
/** list element type, or null. */
function listElement(shape: TypeShape): TypeShape | null {
if (shape.kind === 'list') return shape.inner
return null
}
/** All ref names reachable inside a shape. */
function refsIn(shape: TypeShape): string[] {
switch (shape.kind) {
case 'ref':
return [shape.name]
case 'list':
case 'optional':
return refsIn(shape.inner)
case 'union':
return shape.branches.flatMap(refsIn)
default:
return []
}
}
/** All ref names a NamedType body references. */
function refsInBody(body: NamedType): string[] {
if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape))
if (body.kind === 'alias') return refsIn(body.inner)
return []
}
interface FnTypeInfo {
schema: import('./types').IrSchema
camel: string
}
/**
* First pass: collect every named type the IR's `function` section references,
* keyed by emitted name. Two kinds, exactly as `_collect_named_types`:
* - structs visited anywhere in input/output traversal (under their ref name,
* and under the canonical `<camel>Input` / `<camel>Output` rename)
* - output wrapper aliases (`<camel>Output = list[T]` / primitive / renamed
* model) so the consumer has one named type to reference.
*/
function collectNamedTypes(fns: Map<string, FnTypeInfo>): Record<string, NamedType> {
const seen: Record<string, NamedType> = {}
function visitModel(name: string, types: Record<string, NamedType>): void {
if (name in seen) return
const body = types[name]
if (body === undefined) {
throw new Error(
`IR schema references type "${name}" but no definition was provided in the function's \`types\`.`,
)
}
seen[name] = body
for (const ref of refsInBody(body)) visitModel(ref, types)
}
function visitShape(shape: TypeShape, types: Record<string, NamedType>): void {
for (const ref of refsIn(shape)) visitModel(ref, types)
}
for (const { schema, camel } of fns.values()) {
const types = schema.types ?? {}
// Input — named `<camel>Input`, emitted as a struct.
if (schema.input && schema.input.length > 0) {
const inputName = `${camel}Input`
if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input }
// Visit nested refs in the input fields.
for (const field of schema.input) visitShape(field.shape, types)
}
// Output.
if (schema.output === undefined) continue
const outputName = `${camel}Output`
const [inner] = stripOptional(schema.output)
const elem = listElement(inner)
if (elem !== null) {
// list[T] (possibly Optional) — list alias. Visit element type.
visitShape(schema.output, types)
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
} else if (inner.kind === 'ref') {
// <Model> or Optional[<Model>] — emit the model under the canonical
// output name (rename). Python renames the Pydantic model to
// `<camel>Output`; we emit the referenced struct under that name.
const refName = inner.name
const body = types[refName]
if (body === undefined) {
throw new Error(
`IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`,
)
}
if (body.kind === 'struct') {
// Emit the struct under the canonical output name (the rename),
// and visit its nested refs.
if (!(outputName in seen)) {
seen[outputName] = body
for (const ref of refsInBody(body)) visitModel(ref, types)
}
} else {
// Non-struct named type referenced as output — emit under its
// own name plus a canonical alias.
visitModel(refName, types)
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
}
} else {
// Primitive-wrapped output (`result: int`) — alias.
if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output }
}
}
return seen
}
// ─── Function / context emission ──────────────────────────────────────────
function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } {
const camel = snakeToCamel(entry.name)
const canonical = `${camel}Output`
const schema = entry.ir
if (!schema || schema.output === undefined) return { name: canonical, nullable: false }
const [, nullable] = stripOptional(schema.output)
return { name: canonical, nullable }
}
function emitFunction(em: Emitter, entry: RegistryEntry): void {
const camel = snakeToCamel(entry.name)
const schema = entry.ir ?? {}
const hasInput = !!(schema.input && schema.input.length > 0)
const { name: outputName, nullable } = resolveOutput(entry)
em.open(0, 'function', kdlString(entry.name))
em.leaf(1, 'camel', kdlString(camel))
em.leaf(1, 'has-input', kdlBool(hasInput))
if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`))
em.leaf(1, 'output', kdlString(outputName))
if (nullable) em.leaf(1, 'output-nullable', kdlBool(true))
em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http'))
if (entry.context) em.leaf(1, 'context', kdlString(entry.context))
// Only context-typed affects make it into the KDL (matches ir.py).
for (const a of entry.affects ?? []) {
if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name))
}
for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m))
if (entry.form) {
em.leaf(1, 'is-form', kdlBool(true))
if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName))
if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole))
}
em.close(0)
}
function annotationToPrimitive(shape: TypeShape | undefined): Primitive {
if (shape === undefined) return 'string'
const [inner] = stripOptional(shape)
if (inner.kind === 'primitive') return inner.primitive
return 'string'
}
function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void {
// Collect param info across every function in the context.
interface Slot {
type: Primitive
sharedBy: string[]
}
const paramInfo = new Map<string, Slot>()
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
const input = entry.ir?.input
if (!input || input.length === 0) continue
for (const field of input) {
let slot = paramInfo.get(field.name)
if (!slot) {
slot = { type: 'string', sharedBy: [] }
paramInfo.set(field.name, slot)
}
slot.type = annotationToPrimitive(field.shape)
slot.sharedBy.push(fnName)
}
}
em.open(0, 'context', kdlString(ctxName))
// Members alphabetical — canonical order.
for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName))
for (const paramName of [...paramInfo.keys()].sort()) {
const slot = paramInfo.get(paramName)!
const required = slot.sharedBy.length === fnNames.length
em.open(1, 'param', kdlString(paramName))
em.leaf(2, 'type', kdlString(slot.type))
em.leaf(2, 'required', kdlBool(required))
for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer))
em.close(1)
}
em.close(0)
}
// ─── Top-level builder ──────────────────────────────────────────────────────
/**
* Build the Mizan IR (KDL) for every registered function. Byte-equivalent to
* the Python `build_ir()` against the same registry.
*
* `private` and view-path functions are excluded from the function section,
* matching ir.py.
*/
export function buildIr(): string {
const functions = getAllFunctions()
const contextGroups = getContextGroups()
// Functions contributing to the type/function sections (skip private + view).
const typeFns = new Map<string, FnTypeInfo>()
const emitFns: RegistryEntry[] = []
for (const [name, entry] of functions) {
if (entry.private || entry.viewPath) continue
typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) })
emitFns.push(entry)
}
const namedTypes = collectNamedTypes(typeFns)
const em = new Emitter()
// Types — alphabetical by name (canonical IR ordering).
const typeNames = Object.keys(namedTypes).sort()
for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName])
if (typeNames.length > 0) em.blank()
// Functions — alphabetical by wire name.
emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
for (const entry of emitFns) emitFunction(em, entry)
if (emitFns.length > 0) em.blank()
// Contexts — alphabetical by name.
const ctxNames = Object.keys(contextGroups).sort()
for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName])
if (ctxNames.length > 0) em.blank()
return em.intoString()
}

View File

@@ -0,0 +1,17 @@
/**
* Mizan IR (KDL) — the codegen contract.
*
* `buildIr()` emits KDL byte-identical to the Python `build_ir()` against the
* same registry. This is what lets a TypeScript backend feed
* `protocol/mizan-codegen`.
*/
export { buildIr, snakeToCamel } from './build'
export type {
Primitive,
TypeShape,
DefaultValue,
StructField,
NamedType,
IrSchema,
} from './types'

View File

@@ -0,0 +1,70 @@
/**
* IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and
* `cores/mizan-rust/src/ir.rs` 1:1.
*
* The IR is the contract. Backends emit it; the codegen consumes it. The
* TypeScript side produces byte-equivalent KDL to the Python emitter against
* the same function registry.
*
* TypeScript has no Pydantic to introspect, so the `@client` decorator carries
* an explicit IR type schema (input fields + output shape). That schema is the
* binding: a TS backend declares its IR types, and `buildIr()` emits the KDL
* the codegen reads — exactly as the Rust adapter declares typed `StructField`
* / `TypeShape` registrations.
*/
export type Primitive = 'integer' | 'number' | 'boolean' | 'string'
/**
* An in-place type shape — referenced from struct fields, function
* inputs/outputs, and alias bodies.
*/
export type TypeShape =
| { kind: 'primitive'; primitive: Primitive }
| { kind: 'ref'; name: string }
| { kind: 'list'; inner: TypeShape }
| { kind: 'optional'; inner: TypeShape }
| { kind: 'enum'; variants: string[] }
| { kind: 'union'; branches: TypeShape[] }
| { kind: 'upload'; maxSize?: number; contentTypes?: string[] }
export type DefaultValue =
| { kind: 'integer'; value: number }
| { kind: 'number'; value: number }
| { kind: 'boolean'; value: boolean }
| { kind: 'string'; value: string }
| { kind: 'null' }
export interface StructField {
name: string
required: boolean
default?: DefaultValue
shape: TypeShape
}
/** A named type that appears in the IR's `type "<Name>" { ... }` section. */
export type NamedType =
| { kind: 'struct'; fields: StructField[] }
| { kind: 'alias'; inner: TypeShape }
| { kind: 'enum'; variants: string[] }
/**
* The IR type schema a `@client` function carries.
*
* `input` is the ordered list of input fields (already excluding the implicit
* request/identity arg). When absent or empty, the function `has-input #false`.
*
* `output` is the function's return shape: a `ref` to a named struct, a `list`,
* an `optional`, or a `primitive`. The emitter derives the canonical
* `<camel>Input` / `<camel>Output` names and the struct-vs-alias split exactly
* as `_collect_named_types` does.
*
* `types` resolves every `ref` used in `input`/`output` (and transitively) to
* its `NamedType` definition — Python gets this from Pydantic model
* introspection; TS declares it explicitly.
*/
export interface IrSchema {
input?: StructField[]
output?: TypeShape
types?: Record<string, NamedType>
}

View File

@@ -0,0 +1,46 @@
/**
* Session / CSRF init endpoint — the AFI-common `GET /api/mizan/session/`.
*
* Wired at parity with mizan-django / mizan-fastapi / mizan-rust-axum. The CSRF
* *token* is a Django session mechanism with no TypeScript-runtime equivalent,
* so this returns a null token by default; the endpoint itself is the owed AFI
* surface, and a host that mints CSRF tokens can pass one in. A SPA client uses
* the response as its session-readiness signal.
*/
import type { MizanResponse } from './dispatch'
/**
* Canonical mount path for the session-init endpoint, relative to the Mizan
* mount (`/api/mizan`). A router adapter binds `handleSessionInit` here — the
* same `/session/` route Django (`path("session/")`), FastAPI
* (`@router.get("/session/")`), and Axum register.
*/
export const SESSION_INIT_PATH = '/session/'
/** HTTP method for the session-init route. */
export const SESSION_INIT_METHOD = 'GET'
/**
* Build the session-init response. Returns `{ csrfToken }` with `no-store`.
* `csrfToken` defaults to null (no Django-style session); a host with its own
* CSRF mechanism passes the token to embed.
*/
export function handleSessionInit(csrfToken: string | null = null): MizanResponse {
return {
status: 200,
body: { csrfToken },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/**
* Route descriptor for the session-init endpoint — what a router adapter
* registers: `GET /session/` → `handleSessionInit`. Mirrors the
* `path("session/", session_init_view)` URL entry the Python adapters declare.
*/
export const sessionInitRoute = {
path: SESSION_INIT_PATH,
method: SESSION_INIT_METHOD,
handler: () => handleSessionInit(),
} as const

View File

@@ -0,0 +1,78 @@
/**
* Shapes — typed query projection.
*
* AFI-common capability; the binding is per-ORM. Django's binding is
* django-readers (select named fields + nested relations from a QuerySet in
* one query). The TypeScript binding is the same shape over the data source a
* TS backend already has: a `QueryProjection` declares the fields and nested
* relations to keep, and `project()` produces records carrying *only* those —
* the over-fetch-elimination the Shapes capability exists for, expressed
* against plain records rather than a Django QuerySet.
*
* A projection composes: a relation is itself a `QueryProjection`, so nested
* shapes prune recursively (mirrors `Shape._spec` / `_build_pair`).
*/
/** A declarative projection: scalar fields plus nested relation projections. */
export interface QueryProjection {
/** Scalar field names to keep on each record. */
fields: string[]
/** Nested relations to keep, each projected by its own `QueryProjection`. */
relations?: Record<string, QueryProjection>
}
type Record_ = Record<string, any>
function projectOne(record: Record_, projection: QueryProjection): Record_ {
const out: Record_ = {}
for (const f of projection.fields) {
if (f in record) out[f] = record[f]
}
for (const [name, child] of Object.entries(projection.relations ?? {})) {
const value = record[name]
if (value === undefined || value === null) {
out[name] = value
} else if (Array.isArray(value)) {
out[name] = value.map((v) => projectOne(v, child))
} else {
out[name] = projectOne(value, child)
}
}
return out
}
/**
* Project a list of records through a `QueryProjection`, keeping only the
* declared fields + nested relations. Each output record carries nothing the
* projection didn't name — the typed-projection guarantee.
*/
export function project(records: Record_[], projection: QueryProjection): Record_[] {
return records.map((r) => projectOne(r, projection))
}
/** Project a single record. */
export function projectRecord(record: Record_, projection: QueryProjection): Record_ {
return projectOne(record, projection)
}
/**
* A reusable Shape: binds a `QueryProjection` to a name so a `@client` context
* function can `Shape.query(source)` and return uniformly-projected records.
* The per-ORM source differs; the projection contract does not.
*/
export class Shape {
constructor(
public readonly name: string,
public readonly projection: QueryProjection,
) {}
/** Project a record source through this shape's projection. */
query(source: Record_[]): Record_[] {
return project(source, this.projection)
}
/** Project a single record. */
one(record: Record_): Record_ {
return projectRecord(record, this.projection)
}
}

View File

@@ -0,0 +1,216 @@
/**
* SSR Bridge — manages a persistent Bun subprocess for React server-rendering.
*
* TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire
* protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with
* message-id correlation so concurrent renders don't cross.
*
* → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } }
* ← { "id": 1, "html": "<div>...</div>" }
* ← { "id": 1, "error": "..." } (on failure)
*
* The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file
* and calls `renderToString` — no registry. It announces readiness with
* `{ "id": 0, "ready": true }`; the bridge waits for that before accepting
* renders, and restarts the worker if it exits.
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
export interface SSRBridgeOptions {
/** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */
worker: string
/** Per-render + startup timeout, seconds. Default 5. */
timeout?: number
/** Runtime to launch the worker. Default 'bun'. */
runtime?: string
/**
* Args passed to the runtime before the worker path. Default `['run']`
* (the Bun/`bun run <worker>` convention). Set `[]` for a runtime like
* `node` that takes the script path directly.
*/
runtimeArgs?: string[]
}
export interface RenderResult {
html: string
}
interface Pending {
resolve: (msg: any) => void
reject: (err: Error) => void
timer: ReturnType<typeof setTimeout>
}
export class SSRBridge {
private readonly worker: string
private readonly timeoutMs: number
private readonly runtime: string
private readonly runtimeArgs: string[]
private proc: ChildProcessWithoutNullStreams | null = null
private counter = 0
private buffer = ''
private readonly pending = new Map<number, Pending>()
private readyPromise: Promise<void> | null = null
private readyResolve: (() => void) | null = null
private readyReject: ((err: Error) => void) | null = null
constructor(options: SSRBridgeOptions) {
this.worker = options.worker
this.timeoutMs = (options.timeout ?? 5) * 1000
this.runtime = options.runtime ?? 'bun'
this.runtimeArgs = options.runtimeArgs ?? ['run']
}
private ensureRunning(): Promise<void> {
if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) {
return this.readyPromise
}
let settled = false
this.readyPromise = new Promise<void>((resolve, reject) => {
this.readyResolve = () => {
if (!settled) {
settled = true
resolve()
}
}
this.readyReject = (err) => {
if (!settled) {
settled = true
reject(err)
}
}
})
const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], {
stdio: ['pipe', 'pipe', 'pipe'],
})
this.proc = proc
proc.stdout.setEncoding('utf-8')
proc.stdout.on('data', (chunk: string) => this.onStdout(chunk))
// Only react to THIS proc's exit — a stale exit event (from a worker we
// already replaced) must not null out the freshly-spawned one.
proc.on('exit', () => this.onExit(proc))
proc.on('error', (err) => {
this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`))
})
const startTimer = setTimeout(() => {
this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`))
this.shutdown()
}, this.timeoutMs)
// Clear the start timer once ready settles (either way).
this.readyPromise.then(
() => clearTimeout(startTimer),
() => clearTimeout(startTimer),
)
return this.readyPromise
}
private onStdout(chunk: string): void {
this.buffer += chunk
let nl: number
while ((nl = this.buffer.indexOf('\n')) !== -1) {
const line = this.buffer.slice(0, nl).trim()
this.buffer = this.buffer.slice(nl + 1)
if (!line) continue
let msg: any
try {
msg = JSON.parse(line)
} catch {
continue // malformed line — ignore, matches the Python reader
}
this.onMessage(msg)
}
}
private onMessage(msg: any): void {
// Ready signal (id=0).
if (msg.id === 0 && msg.ready) {
this.readyResolve?.()
return
}
const id = msg.id
if (typeof id === 'number' && this.pending.has(id)) {
const p = this.pending.get(id)!
this.pending.delete(id)
clearTimeout(p.timer)
p.resolve(msg)
}
}
private onExit(proc: ChildProcessWithoutNullStreams): void {
// Ignore exit events from a worker we've already replaced.
if (this.proc !== null && this.proc !== proc) return
// Fail any in-flight requests; the next call re-spawns a fresh worker.
const err = new Error('SSR worker exited')
for (const [, p] of this.pending) {
clearTimeout(p.timer)
p.reject(err)
}
this.pending.clear()
this.readyReject?.(err)
this.proc = null
this.readyPromise = null
}
private request(method: string, params: Record<string, any>): Promise<any> {
const id = ++this.counter
const frame = JSON.stringify({ id, method, params }) + '\n'
return new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`))
}, this.timeoutMs)
this.pending.set(id, { resolve, reject, timer })
try {
this.proc!.stdin.write(frame)
} catch (e: any) {
this.pending.delete(id)
clearTimeout(timer)
reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`))
}
})
}
/** Render a React component file to HTML. Spawns the worker on first use. */
async render(file: string, props: Record<string, any> = {}): Promise<RenderResult> {
await this.ensureRunning()
const msg = await this.request('render', { file, props })
if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`)
return { html: msg.html }
}
/** Health check — resolves true when the worker answers a ping. */
async ping(): Promise<boolean> {
await this.ensureRunning()
const msg = await this.request('ping', {})
return msg.pong === true
}
/** Stop the Bun subprocess. */
shutdown(): void {
if (this.proc !== null) {
try {
this.proc.stdin.end()
} catch {
/* already closed */
}
try {
this.proc.kill()
} catch {
/* already gone */
}
this.proc = null
this.readyPromise = null
}
}
}

View File

@@ -1,14 +1,56 @@
/**
* MWT / JWT decode — HS256 verification, cross-language parity with
* cores/mizan-python/src/mizan_core/mwt.py.
* MWT / JWT mint + decode — HS256, cross-language parity with
* `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`.
*
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
* aud, malformed). Never throws.
* Decode returns null on ANY failure (bad signature, expired, future nbf,
* wrong aud, malformed) and never throws. Mint is byte-identical to PyJWT's
* `jwt.encode(...)`: the JOSE header is serialized with sorted keys, the
* payload preserves insertion order, both with `(",", ":")` separators and
* base64url-without-padding — so a TS-minted token equals a Python-minted one
* for the same claims. `tests/token.test.ts` pins this against the live Python
* mint via subprocess.
*/
import { createHmac, timingSafeEqual } from 'crypto'
import { createHash, createHmac, timingSafeEqual } from 'crypto'
import type { Identity } from './identity'
// ─── HS256 JWS serialization (PyJWT byte-parity) ──────────────────────────────
function base64urlEncode(buf: Buffer | string): string {
return Buffer.from(buf).toString('base64url')
}
/**
* Serialize a JSON object the way PyJWT does — compact `(",", ":")` separators.
* `sortKeys` matches PyJWT: the JOSE header is emitted with sorted keys; the
* payload preserves the object's own (insertion) order. Mirrors Python's
* `json.dumps(obj, separators=(",", ":"), sort_keys=...)`.
*/
function compactJson(obj: Record<string, unknown>, sortKeys: boolean): string {
if (!sortKeys) return JSON.stringify(obj)
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]
return JSON.stringify(sorted)
}
/**
* Sign an HS256 JWS. `header` extras (e.g. `{kid}`) merge over the base
* `{alg, typ}`; the JOSE header is serialized with sorted keys, exactly as
* PyJWT's `api_jws.encode`. Returns `header.payload.signature` (base64url).
*/
export function signHs256(
payload: Record<string, unknown>,
secret: string,
headerExtras: Record<string, unknown> = {},
): string {
const header = { alg: 'HS256', typ: 'JWT', ...headerExtras }
const headerB64 = base64urlEncode(compactJson(header, true))
const payloadB64 = base64urlEncode(compactJson(payload, false))
const signing = `${headerB64}.${payloadB64}`
const sig = createHmac('sha256', secret).update(signing).digest('base64url')
return `${signing}.${sig}`
}
export interface MwtPayload {
sub: string
staff: boolean
@@ -108,3 +150,115 @@ export function identityFromMwt(payload: MwtPayload): Identity {
id: Number(payload.sub),
}
}
// ─── MWT mint (byte-parity with mwt.create_mwt) ───────────────────────────────
/** A user-shaped source for minting. Mirrors the fields create_mwt reads. */
export interface MintUser {
pk: number | string
isStaff?: boolean
isSuperuser?: boolean
/** All permission strings, in any order (sorted here, as Python does). */
permissions?: string[]
}
/**
* Deterministic hash of permission state — byte-identical to
* `mwt.compute_permission_key`: SHA-256 over `"{staff}:{super}:{sorted_perms}"`.
*/
export function computePermissionKey(user: MintUser): string {
const perms = [...(user.permissions ?? [])].sort()
const staff = user.isStaff ? '1' : '0'
const superuser = user.isSuperuser ? '1' : '0'
const blob = `${staff}:${superuser}:${perms.join(',')}`
return createHash('sha256').update(blob, 'utf-8').digest('hex')
}
/**
* Sign an MWT for `user`. Byte-identical to `mwt.create_mwt`: claims in order
* `sub, staff, super, pkey, aud, iat, nbf, exp`; `kid` in the JOSE header.
*/
export function signMwt(
user: MintUser,
secret: string,
options: { ttl?: number; audience?: string; kid?: string; now?: number } = {},
): string {
const { ttl = 300, audience = 'mizan', kid = 'v1', now = Math.floor(Date.now() / 1000) } = options
const payload = {
sub: String(user.pk),
staff: Boolean(user.isStaff),
super: Boolean(user.isSuperuser),
pkey: computePermissionKey(user),
aud: audience,
iat: now,
nbf: now,
exp: now + ttl,
}
return signHs256(payload, secret, { kid })
}
/** Alias matching the `mintXxx` naming the protocol-parity surface expects. */
export const mintMwt = signMwt
// ─── JWT access/refresh mint (byte-parity with auth.jwt._mint) ────────────────
export interface JwtConfig {
privateKey: string
algorithm?: 'HS256'
accessTokenExpiresIn?: number
refreshTokenExpiresIn?: number
}
export interface JwtMintClaims {
userId: number | string
sessionKey: string
isStaff?: boolean
isSuperuser?: boolean
}
/**
* Mint one HS256 JWT. Byte-identical to `auth.jwt._mint`: claims in order
* `sub, sid, staff, super, type, iat, exp`. No custom JOSE header (PyJWT emits
* the bare `{alg, typ}` header for `jwt.encode` without `headers=`).
*/
export function signJwt(
claims: JwtMintClaims,
tokenType: 'access' | 'refresh',
ttl: number,
config: JwtConfig,
now: number = Math.floor(Date.now() / 1000),
): string {
const payload = {
sub: String(claims.userId),
sid: claims.sessionKey,
staff: Boolean(claims.isStaff),
super: Boolean(claims.isSuperuser),
type: tokenType,
iat: now,
exp: now + ttl,
}
return signHs256(payload, config.privateKey)
}
export function createAccessToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
return signJwt(claims, 'access', config.accessTokenExpiresIn ?? 300, config, now)
}
export function createRefreshToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string {
return signJwt(claims, 'refresh', config.refreshTokenExpiresIn ?? 604800, config, now)
}
export interface JwtTokenPair {
accessToken: string
refreshToken: string
expiresIn: number
}
/** Mint an access+refresh pair. Mirrors `auth.jwt.create_token_pair`. */
export function mintJwt(claims: JwtMintClaims, config: JwtConfig, now?: number): JwtTokenPair {
return {
accessToken: createAccessToken(claims, config, now),
refreshToken: createRefreshToken(claims, config, now),
expiresIn: config.accessTokenExpiresIn ?? 300,
}
}

View File

@@ -3,6 +3,7 @@
*/
import type { AuthPredicate } from './identity'
import type { IrSchema } from './ir/types'
export class ReactContext {
constructor(public readonly name: string) {
@@ -18,15 +19,31 @@ export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
/** Normalized auth requirement as stored on the registry entry. */
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
/** Form role for a forms-binding function (schema / validate / submit). */
export type FormRole = 'schema' | 'validate' | 'submit'
export interface ClientOptions {
context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[]
/** Contexts the mutation's return value merges into (vs. refetch). */
merge?: AffectsTarget | AffectsTarget[]
private?: boolean
route?: string
methods?: string[]
auth?: AuthOption
websocket?: boolean
rev?: number
cache?: number | false
/**
* IR type schema (input fields + output shape). TypeScript has no Pydantic
* to introspect, so the codegen IR is declared here. Without it the
* function still dispatches, but `buildIr()` cannot emit its types.
*/
ir?: IrSchema
/** Forms binding: marks this as a form function and names its role. */
form?: boolean
formName?: string
formRole?: FormRole
}
export interface ParamDef {
@@ -40,14 +57,20 @@ export interface RegistryEntry {
fn: (...args: any[]) => Promise<any>
context?: string
affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }>
merge?: string[]
params: ParamDef[]
private: boolean
viewPath: boolean
route?: string
methods?: string[]
auth?: AuthRequirement
websocket?: boolean
rev?: number
cache?: number | false
ir?: IrSchema
form?: boolean
formName?: string
formRole?: FormRole
}
export interface ManifestContext {

View File

@@ -0,0 +1,143 @@
/**
* Mizan Upload — first-class binary input for `@client` functions.
*
* Mirrors `cores/mizan-python/src/mizan_core/upload.py`. Declaring an
* Upload-typed field in a function's `ir.input` makes a call multipart-aware:
* the generated client switches to `multipart/form-data`, and dispatch binds
* each file part into a uniform `UploadedFile` on the function's args.
* Constraints declared via `File` (max size, content types) are enforced at
* dispatch, exactly as the Python `validate_upload` enforces them.
*
* TypeScript has no Pydantic to introspect, so the Upload fields are read from
* the function's declared `ir.input` shapes (`{ kind: 'upload', ... }`) rather
* than from model metadata.
*/
import type { RegistryEntry } from './types'
import type { TypeShape } from './ir/types'
const SIZE_UNITS: Array<[string, number]> = [
['GB', 1024 ** 3],
['MB', 1024 ** 2],
['KB', 1024],
['B', 1],
]
/** Parse a byte count. Accepts a number (bytes) or a string like `"5MB"`. */
export function parseSize(value: number | string): number {
if (typeof value === 'number') return value
const s = value.trim().toUpperCase()
for (const [unit, mult] of SIZE_UNITS) {
if (s.endsWith(unit)) return Math.trunc(parseFloat(s.slice(0, -unit.length).trim()) * mult)
}
return Math.trunc(Number(s))
}
/** Declarative constraints for an Upload field. */
export interface File {
maxSize?: number
contentTypes?: string[]
}
/**
* Uniform file handle handed to `@client` functions — adapter-agnostic.
* Constructed by dispatch from a multipart `Blob`/`File` part.
*/
export class UploadedFile {
constructor(
public readonly filename: string | null,
public readonly contentType: string | null,
private readonly data: Uint8Array,
) {}
get size(): number {
return this.data.byteLength
}
read(): Uint8Array {
return this.data
}
text(): string {
return new TextDecoder().decode(this.data)
}
}
function contentTypeAllowed(contentType: string | null, allowed: string[]): boolean {
if (!contentType) return false
for (const ct of allowed) {
if (ct === contentType) return true
if (ct.endsWith('/*') && contentType.startsWith(ct.slice(0, -1))) return true
}
return false
}
/** Enforce declared constraints. Returns an error message, or null if ok. */
export function validateUpload(file: UploadedFile, spec: File | undefined): string | null {
if (!spec) return null
if (spec.maxSize !== undefined && file.size > spec.maxSize) {
return `file exceeds max size ${spec.maxSize} bytes (got ${file.size})`
}
if (spec.contentTypes && spec.contentTypes.length > 0 && !contentTypeAllowed(file.contentType, spec.contentTypes)) {
return `content-type ${JSON.stringify(file.contentType)} not allowed (expected one of ${JSON.stringify(spec.contentTypes)})`
}
return null
}
/** An Upload field on a function input: name → (isList, spec). */
interface UploadField {
isList: boolean
spec: File | undefined
}
/** Unwrap Optional/list around an `upload` shape → [isUpload, isList, spec]. */
function classifyUpload(shape: TypeShape): { isUpload: boolean; isList: boolean; spec: File | undefined } {
let s = shape
if (s.kind === 'optional') s = s.inner
let isList = false
if (s.kind === 'list') {
isList = true
s = s.inner
}
if (s.kind === 'upload') {
const spec: File = {}
if (s.maxSize !== undefined) spec.maxSize = s.maxSize
if (s.contentTypes !== undefined) spec.contentTypes = s.contentTypes
const hasSpec = s.maxSize !== undefined || s.contentTypes !== undefined
return { isUpload: true, isList, spec: hasSpec ? spec : undefined }
}
return { isUpload: false, isList: false, spec: undefined }
}
/** Map each Upload-typed field of a function's input → (isList, spec). */
export function uploadFields(entry: RegistryEntry): Map<string, UploadField> {
const out = new Map<string, UploadField>()
for (const field of entry.ir?.input ?? []) {
const { isUpload, isList, spec } = classifyUpload(field.shape)
if (isUpload) out.set(field.name, { isList, spec })
}
return out
}
/**
* 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 (a list field receives several). Returns an error message on the first
* constraint violation, else null. Mirrors `upload.bind_uploads`.
*/
export function bindUploads(
entry: RegistryEntry,
args: Record<string, any>,
files: Map<string, UploadedFile[]>,
): string | null {
for (const [name, { isList, spec }] of uploadFields(entry)) {
const bucket = files.get(name) ?? []
if (bucket.length === 0) continue
for (const f of bucket) {
const err = validateUpload(f, spec)
if (err !== null) return `${name}: ${err}`
}
args[name] = isList ? [...bucket] : bucket[0]
}
return null
}

View File

@@ -0,0 +1,116 @@
/**
* WebSocket transport — RPC over a WebSocket connection for
* `@client({ websocket: true })` functions.
*
* Parity with the Django Channels consumer and the Axum WebSocket handler: the
* client sends JSON-RPC frames and receives correlated replies. Both the
* mutation (`call`) and the bundled context (`fetch`) verbs route through the
* *same* dispatch core the HTTP path uses, so invalidation, auth, and caching
* behave identically on either transport — only the framing differs.
*
* Frame protocol (newline-free JSON, one object per WS message):
*
* → { "id": 1, "type": "call", "fn": "update_profile", "args": { ... } }
* ← { "id": 1, "result": { ... }, "invalidate": [ ... ] }
*
* → { "id": 2, "type": "fetch", "context": "user", "params": { ... } }
* ← { "id": 2, "result": { user_profile: { ... }, ... } }
*
* ← { "id": N, "error": { "code": "...", "message": "..." } } (on failure)
*
* The `id` echoes back so a client can correlate concurrent in-flight calls
* over one socket.
*/
import { handleContextFetch, handleMutationCall } from './dispatch'
import { ANONYMOUS, type Identity } from './identity'
interface CallFrame {
id?: number | string
type: 'call'
fn: string
args?: Record<string, any>
}
interface FetchFrame {
id?: number | string
type: 'fetch'
context: string
params?: Record<string, string>
}
export type MizanWsFrame = CallFrame | FetchFrame
export interface MizanWsReply {
id?: number | string
result?: any
invalidate?: any
error?: { code: string; message: string }
}
/**
* Handle one inbound WebSocket frame and produce the reply object.
*
* `raw` is the message payload (string or already-parsed object). Routing is by
* the frame `type`; the body of the work is the same dispatch the HTTP handlers
* call, so a function exposed over both transports behaves identically.
*/
export async function handleWebSocketMessage(
raw: string | MizanWsFrame,
identity: Identity = ANONYMOUS,
): Promise<MizanWsReply> {
let frame: MizanWsFrame
try {
frame = typeof raw === 'string' ? JSON.parse(raw) : raw
} catch {
return { error: { code: 'BAD_REQUEST', message: 'Invalid JSON frame' } }
}
const id = (frame as { id?: number | string }).id
if (frame.type === 'call') {
if (!frame.fn) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'fn'" } }
const res = await handleMutationCall(frame.fn, frame.args ?? {}, identity)
if (res.status !== 200) {
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
}
const reply: MizanWsReply = { id, result: res.body.result }
if (res.body.invalidate !== undefined) reply.invalidate = res.body.invalidate
return reply
}
if (frame.type === 'fetch') {
if (!frame.context) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'context'" } }
const res = await handleContextFetch(frame.context, frame.params ?? {}, identity)
if (res.status !== 200) {
return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } }
}
return { id, result: res.body }
}
return { id, error: { code: 'BAD_REQUEST', message: `Unknown frame type` } }
}
/** Minimal structural type for a WebSocket-like connection. */
export interface WebSocketLike {
send(data: string): void
addEventListener(type: 'message', listener: (event: { data: any }) => void): void
}
/**
* Attach the Mizan RPC protocol to a `WebSocket`-like connection. Each inbound
* message is dispatched via `handleWebSocketMessage` and the reply is sent back
* as JSON. `identity` resolves the caller (host wires MWT/JWT decode here).
*/
export function serveWebSocket(
ws: WebSocketLike,
identity: Identity = ANONYMOUS,
): void {
ws.addEventListener('message', async (event) => {
const reply = await handleWebSocketMessage(
typeof event.data === 'string' ? event.data : String(event.data),
identity,
)
ws.send(JSON.stringify(reply))
})
}

View File

@@ -0,0 +1,6 @@
import { createElement } from 'react'
/** SSR fixture component — rendered by the Bun worker in the bridge test. */
export default function Hello({ name }: { name: string }) {
return createElement('div', { className: 'greeting' }, `Hello, ${name}!`)
}

View File

@@ -0,0 +1,53 @@
/**
* Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited
* JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React
* dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess
* machinery (ready handshake, id correlation, render reply, ping, error frame)
* under plain Node, independent of the real worker's install state.
*
* `render` echoes the props into a deterministic HTML string so the bridge's
* request/response correlation is observable; a file named "*boom*" yields an
* error frame to prove the failure path.
*/
function respond(msg) {
process.stdout.write(JSON.stringify(msg) + '\n')
}
function handle(msg) {
if (msg.method === 'ping') {
respond({ id: msg.id, pong: true })
return
}
if (msg.method === 'render') {
const { file, props } = msg.params ?? {}
if (typeof file === 'string' && file.includes('boom')) {
respond({ id: msg.id, error: `cannot render ${file}` })
return
}
respond({ id: msg.id, html: `<div data-file="${file}">${JSON.stringify(props ?? {})}</div>` })
return
}
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
}
let buffer = ''
process.stdin.setEncoding('utf-8')
process.stdin.on('data', (chunk) => {
buffer += chunk
let nl
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim()
buffer = buffer.slice(nl + 1)
if (line) {
try {
handle(JSON.parse(line))
} catch (e) {
respond({ id: -1, error: e.message })
}
}
}
})
// Ready handshake — identical to the real worker.
respond({ id: 0, ready: true })

View File

@@ -0,0 +1,149 @@
/**
* The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1.
*
* Each function declares the same IR type schema the Python fixture's Pydantic
* Input/Output models imply, so `buildIr()` here emits the same KDL the Python
* `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`)
* subprocesses the live Python emitter and asserts equality.
*
* Output structs are declared under their model name (`ProfileOutput`,
* `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames
* them to the canonical `<camel>Output`, exactly as `_collect_named_types`
* renames the Pydantic models.
*/
import { client, ReactContext } from '../src'
import type { NamedType, StructField } from '../src'
const intField = (name: string): StructField => ({
name,
required: true,
shape: { kind: 'primitive', primitive: 'integer' },
})
const strField = (name: string): StructField => ({
name,
required: true,
shape: { kind: 'primitive', primitive: 'string' },
})
const boolField = (name: string): StructField => ({
name,
required: true,
shape: { kind: 'primitive', primitive: 'boolean' },
})
const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] }
const OrderOutput: NamedType = {
kind: 'struct',
fields: [intField('id'), intField('user_id'), intField('total')],
}
const UserCtx = new ReactContext('user')
/** Register the AFI fixture functions with the mizan-ts registry. */
export function registerFixture(): void {
// echo — plain function, typed input + struct output.
client(
{
ir: {
input: [strField('text')],
output: { kind: 'ref', name: 'EchoOutput' },
types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } },
},
},
async function echo(text: string) {
return { message: `echo: ${text}` }
},
)
// whoami — no input.
client(
{
ir: {
output: { kind: 'ref', name: 'WhoamiOutput' },
types: {
WhoamiOutput: {
kind: 'struct',
fields: [strField('email'), boolField('authenticated')],
},
},
},
},
async function whoami() {
return { email: 'anon@example.com', authenticated: false }
},
)
// user_profile — context member.
client(
{
context: UserCtx,
ir: {
input: [intField('user_id')],
output: { kind: 'ref', name: 'ProfileOutput' },
types: { ProfileOutput },
},
},
async function user_profile(user_id: number) {
return { user_id, name: 'placeholder' }
},
)
// user_orders — context member, list output, same param (param elevation).
client(
{
context: UserCtx,
ir: {
input: [intField('user_id')],
output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } },
types: { OrderOutput },
},
},
async function user_orders(_user_id: number) {
return []
},
)
// update_profile — mutation affecting the user context.
client(
{
affects: UserCtx,
ir: {
input: [intField('user_id'), strField('name')],
output: { kind: 'ref', name: 'StatusOutput' },
types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } },
},
},
async function update_profile(_user_id: number, _name: string) {
return { ok: true }
},
)
// find_user — optional return.
client(
{
ir: {
input: [intField('user_id')],
output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } },
types: { ProfileOutput },
},
},
async function find_user(_user_id: number) {
return null
},
)
// rename_user — merge target.
client(
{
merge: UserCtx,
ir: {
input: [intField('user_id'), strField('name')],
output: { kind: 'ref', name: 'ProfileOutput' },
types: { ProfileOutput },
},
},
async function rename_user(user_id: number, name: string) {
return { user_id, name }
},
)
}

View File

@@ -0,0 +1,159 @@
/**
* KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python
* `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`).
*
* The IR is the codegen contract. A TypeScript backend can only feed
* `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends
* emit for the same registry. This test reconstructs the AFI fixture in both
* languages, subprocesses the live Python emitter, and asserts byte-equality —
* the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { execFileSync } from 'child_process'
import { existsSync } from 'fs'
import { resolve } from 'path'
import { buildIr, clearRegistry } from '../src'
import { registerFixture } from './ir-fixture'
const REPO_ROOT = resolve(import.meta.dir, '../../..')
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
/**
* Reconstruct the AFI fixture in Python via `mizan_core` only (no backend
* adapter dependency) and emit `build_ir()`. This is the cross-language oracle:
* the same registrations the TS fixture makes, run through the reference
* emitter.
*/
const PY_FIXTURE = String.raw`
import sys
from typing import Optional
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core import registry as reg
from mizan_core.ir import build_ir
reg.clear_registry()
class EchoOutput(BaseModel):
message: str
class WhoamiOutput(BaseModel):
email: str
authenticated: bool
class ProfileOutput(BaseModel):
user_id: int
name: str
class OrderOutput(BaseModel):
id: int
user_id: int
total: int
class StatusOutput(BaseModel):
ok: bool
@client
def echo(request, text: str) -> EchoOutput: ...
@client
def whoami(request) -> WhoamiOutput: ...
@client(context="user")
def user_profile(request, user_id: int) -> ProfileOutput: ...
@client(context="user")
def user_orders(request, user_id: int) -> list[OrderOutput]: ...
@client(affects="user")
def update_profile(request, user_id: int, name: str) -> StatusOutput: ...
@client
def find_user(request, user_id: int) -> Optional[ProfileOutput]: ...
@client(merge="user")
def rename_user(request, user_id: int, name: str) -> ProfileOutput: ...
for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]:
reg.register(f, f.__name__)
sys.stdout.write(build_ir())
`
function pythonBuildIr(): string {
return execFileSync(
'uv',
['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE],
{ encoding: 'utf-8' },
)
}
const UV_AVAILABLE = (() => {
try {
execFileSync('uv', ['--version'], { stdio: 'ignore' })
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
} catch {
return false
}
})()
describe('KDL IR — buildIr()', () => {
beforeEach(() => clearRegistry())
test('emits the canonical type / function / context sections', () => {
registerFixture()
const kdl = buildIr()
// Types are alphabetical; output structs renamed to <camel>Output.
expect(kdl).toContain('type "OrderOutput" {')
expect(kdl).toContain('type "echoInput" {')
expect(kdl).toContain('type "findUserOutput" {')
expect(kdl).toContain('type "userOrdersOutput" {')
// Functions alphabetical, with transport + context/affects/merge leaves.
expect(kdl).toContain('function "echo" {')
expect(kdl).toContain(' camel "echo"')
expect(kdl).toContain(' has-input #true')
expect(kdl).toContain(' output-nullable #true') // find_user
expect(kdl).toContain(' affects "user"') // update_profile
expect(kdl).toContain(' merge "user"') // rename_user
// Context section with shared param elevation.
expect(kdl).toContain('context "user" {')
expect(kdl).toContain(' shared-by "user_orders"')
expect(kdl).toContain(' shared-by "user_profile"')
})
test('has-input #false for a no-arg function', () => {
registerFixture()
const kdl = buildIr()
const whoami = kdl.slice(kdl.indexOf('function "whoami" {'))
expect(whoami).toContain('has-input #false')
expect(whoami).not.toContain('input "whoamiInput"')
})
test.skipIf(!UV_AVAILABLE)(
'byte-identical to the Python build_ir() (cores/mizan-python)',
() => {
registerFixture()
const tsKdl = buildIr()
const pyKdl = pythonBuildIr()
// Line-by-line first so a divergence names the offending line.
const tsLines = tsKdl.split('\n')
const pyLines = pyKdl.split('\n')
const n = Math.max(tsLines.length, pyLines.length)
for (let i = 0; i < n; i++) {
if (tsLines[i] !== pyLines[i]) {
throw new Error(
`KDL diverges at line ${i + 1}:\n` +
` python: ${JSON.stringify(pyLines[i])}\n` +
` ts: ${JSON.stringify(tsLines[i])}`,
)
}
}
expect(tsKdl).toBe(pyKdl)
},
)
})

View File

@@ -0,0 +1,167 @@
/**
* Shapes (typed query projection) + Forms (schema / validate / submit) tests.
*
* Shapes prove over-fetch elimination: the projected record carries only the
* declared fields + nested relations, nothing else. Forms prove the three
* roles register as dispatchable `@client` functions carrying the IR's
* `form`/`form-name`/`form-role` meta, and that validate/submit enforce the
* declared field rules.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import {
clearRegistry,
getFunction,
handleMutationCall,
Shape,
project,
registerForm,
formSchema,
validateForm,
type QueryProjection,
} from '../src'
describe('Shapes — typed query projection', () => {
test('keeps only declared scalar fields', () => {
const projection: QueryProjection = { fields: ['id', 'name'] }
const out = project([{ id: 1, name: 'A', secret: 'x', internal: 42 }], projection)
expect(out).toEqual([{ id: 1, name: 'A' }])
expect(out[0]).not.toHaveProperty('secret')
expect(out[0]).not.toHaveProperty('internal')
})
test('prunes nested relations recursively', () => {
const projection: QueryProjection = {
fields: ['id'],
relations: { orders: { fields: ['total'] } },
}
const out = project(
[{ id: 1, name: 'drop', orders: [{ id: 9, total: 100, hidden: true }] }],
projection,
)
expect(out).toEqual([{ id: 1, orders: [{ total: 100 }] }])
expect(out[0].orders[0]).not.toHaveProperty('hidden')
})
test('handles single-object relation + null', () => {
const projection: QueryProjection = {
fields: ['id'],
relations: { profile: { fields: ['bio'] } },
}
const out = project(
[
{ id: 1, profile: { bio: 'hi', age: 30 } },
{ id: 2, profile: null },
],
projection,
)
expect(out[0]).toEqual({ id: 1, profile: { bio: 'hi' } })
expect(out[1]).toEqual({ id: 2, profile: null })
})
test('Shape.query binds a projection to a source', () => {
const UserShape = new Shape('user', { fields: ['id', 'email'] })
const out = UserShape.query([{ id: 1, email: 'a@b.c', password: 'nope' }])
expect(out).toEqual([{ id: 1, email: 'a@b.c' }])
})
})
describe('Forms — schema / validate / submit', () => {
beforeEach(() => clearRegistry())
const contactForm = {
fields: [
{ name: 'email', type: 'email', required: true, label: 'Email' },
{
name: 'age',
type: 'number',
required: false,
validate: (v: unknown) => (Number(v) < 0 ? 'must be non-negative' : null),
},
],
}
test('formSchema produces field definitions', () => {
const schema = formSchema(contactForm)
expect(schema.fields).toHaveLength(2)
expect(schema.fields[0]).toEqual({
name: 'email',
type: 'email',
required: true,
label: 'Email',
helpText: '',
choices: null,
initial: null,
})
// Default label derived from name when omitted.
expect(schema.fields[1].label).toBe('Age')
})
test('validateForm: required + custom validator', () => {
expect(validateForm(contactForm, { email: 'a@b.c' }).valid).toBe(true)
expect(validateForm(contactForm, {}).errors.email).toEqual(['This field is required.'])
expect(validateForm(contactForm, { email: 'a@b.c', age: -1 }).errors.age).toEqual([
'must be non-negative',
])
})
test('registerForm registers schema + validate + submit with form meta', () => {
const reg = registerForm(contactForm, 'contact', {
submit: async (data) => ({ saved: data.email }),
})
expect(reg).toEqual({ schema: 'contact-schema', validate: 'contact-validate', submit: 'contact-submit' })
for (const [wire, role] of [
['contact-schema', 'schema'],
['contact-validate', 'validate'],
['contact-submit', 'submit'],
] as const) {
const entry = getFunction(wire)
expect(entry).toBeDefined()
expect(entry!.form).toBe(true)
expect(entry!.formName).toBe('contact')
expect(entry!.formRole).toBe(role)
}
})
test('schema function dispatches to the field defs', async () => {
registerForm(contactForm, 'contact')
const r = await handleMutationCall('contact-schema', {})
expect(r.status).toBe(200)
expect(r.body.result.fields).toHaveLength(2)
})
test('validate function dispatches and rejects bad data', async () => {
registerForm(contactForm, 'contact')
const ok = await handleMutationCall('contact-validate', { data: { email: 'a@b.c' } })
expect(ok.body.result.valid).toBe(true)
const bad = await handleMutationCall('contact-validate', { data: {} })
expect(bad.body.result.valid).toBe(false)
expect(bad.body.result.errors.email).toBeDefined()
})
test('submit validates then runs the handler', async () => {
let handled: any = null
registerForm(contactForm, 'contact', {
submit: async (data) => {
handled = data
return { id: 7 }
},
})
const ok = await handleMutationCall('contact-submit', { data: { email: 'a@b.c' } })
expect(ok.body.result).toEqual({ ok: true, result: { id: 7 } })
expect(handled).toEqual({ email: 'a@b.c' })
const bad = await handleMutationCall('contact-submit', { data: {} })
expect(bad.body.result.ok).toBe(false)
expect(bad.body.result.errors.email).toBeDefined()
})
test('submit not registered without a handler', () => {
const reg = registerForm(contactForm, 'noSubmit')
expect(reg.submit).toBeUndefined()
expect(getFunction('noSubmit-submit')).toBeUndefined()
})
})

View File

@@ -0,0 +1,101 @@
/**
* SSR bridge tests — spawn + drive a JSON-RPC worker subprocess.
*
* The bridge's contract is the newline-delimited JSON-RPC protocol over a
* spawned worker (ready handshake, id-correlated render/ping, error frames,
* timeout, restart). Two peers exercise it:
*
* - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always
* runs, proving the full subprocess machinery independent of any install;
* - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an
* actual React component — runs when `bun` + the worker's deps are present.
*/
import { describe, test, expect, afterEach } from 'bun:test'
import { execFileSync } from 'child_process'
import { existsSync } from 'fs'
import { resolve } from 'path'
import { SSRBridge } from '../src'
const HERE = import.meta.dir
const REPO_ROOT = resolve(HERE, '../../..')
const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs')
const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx')
const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx')
// The real worker renders an actual React component. bun resolves `react`
// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's
// own react devDependency (installed alongside this package).
const BUN_OK = (() => {
try {
execFileSync('bun', ['--version'], { stdio: 'ignore' })
return existsSync(resolve(HERE, '../node_modules/react/package.json'))
} catch {
return false
}
})()
let bridge: SSRBridge | null = null
afterEach(() => {
bridge?.shutdown()
bridge = null
})
describe('SSRBridge — stub worker (Node, no React)', () => {
test('waits for ready, then renders with id correlation', async () => {
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 })
expect(r.html).toBe('<div data-file="/abs/Card.tsx">{"title":"Hi","n":3}</div>')
})
test('ping health check', async () => {
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
expect(await bridge.ping()).toBe(true)
})
test('concurrent renders stay correlated', async () => {
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
const [a, b, c] = await Promise.all([
bridge.render('/a.tsx', { k: 'a' }),
bridge.render('/b.tsx', { k: 'b' }),
bridge.render('/c.tsx', { k: 'c' }),
])
expect(a.html).toContain('"k":"a"')
expect(b.html).toContain('"k":"b"')
expect(c.html).toContain('"k":"c"')
})
test('worker error frame surfaces as a thrown error', async () => {
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed')
})
test('restarts after the worker exits', async () => {
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
const first = await bridge.render('/one.tsx', { k: 1 })
expect(first.html).toContain('"k":1')
bridge.shutdown() // simulate a crashed/stopped worker
const second = await bridge.render('/two.tsx', { k: 2 })
expect(second.html).toContain('"k":2')
})
test('startup timeout when the worker never signals ready', async () => {
// `true` exits immediately without a ready frame → start times out.
bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 })
await expect(bridge.render('/x.tsx', {})).rejects.toThrow()
})
})
describe('SSRBridge — real Bun worker (renderToString)', () => {
test.skipIf(!BUN_OK)('renders a React component to HTML', async () => {
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
const r = await bridge.render(HELLO_TSX, { name: 'Ryth' })
expect(r.html).toContain('Hello, Ryth!')
expect(r.html).toContain('class="greeting"')
})
test.skipIf(!BUN_OK)('ping on the real worker', async () => {
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
expect(await bridge.ping()).toBe(true)
})
})

View File

@@ -1,10 +1,23 @@
/**
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
* MWT / JWT token tests — decode round-trip + cross-language byte-parity pins
* against the live Python mint (`cores/mizan-python`).
*/
import { describe, test, expect } from 'bun:test'
import { createHmac } from 'crypto'
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
import { createHmac, createHash } from 'crypto'
import { execFileSync } from 'child_process'
import { existsSync } from 'fs'
import { resolve } from 'path'
import {
decodeMwt,
decodeJwtBearer,
identityFromMwt,
signMwt,
computePermissionKey,
createAccessToken,
createRefreshToken,
type MintUser,
} from '../src'
function b64url(buf: Buffer | string): string {
return Buffer.from(buf).toString('base64url')
@@ -124,3 +137,117 @@ describe('MWT cross-language pin (Python create_mwt)', () => {
})
})
})
// ─── Mint: round-trip + cross-language byte-parity ────────────────────────────
const REPO_ROOT = resolve(import.meta.dir, '../../..')
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
const UV_AVAILABLE = (() => {
try {
execFileSync('uv', ['--version'], { stdio: 'ignore' })
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
} catch {
return false
}
})()
/**
* Run a Python snippet against cores/mizan-python and return stdout (trimmed).
* `time.time` is pinned so the production mint functions are deterministic.
*/
function py(snippet: string): string {
return execFileSync('uv', ['run', '--project', MIZAN_PYTHON, 'python', '-c', snippet], {
encoding: 'utf-8',
}).trim()
}
describe('MWT mint — round-trip', () => {
const SECRET = 'mint-roundtrip-secret'
test('signMwt produces a token decodeMwt accepts', () => {
const user: MintUser = { pk: 7, isStaff: true, isSuperuser: false, permissions: ['a.view', 'a.edit'] }
const token = signMwt(user, SECRET, { now: Math.floor(Date.now() / 1000) })
const p = decodeMwt(token, SECRET)
expect(p).not.toBeNull()
expect(p!.sub).toBe('7')
expect(p!.staff).toBe(true)
expect(p!.super).toBe(false)
expect(p!.kid).toBe('v1')
expect(p!.aud).toBe('mizan')
// pkey is the permission hash, surviving the round-trip.
expect(p!.pkey).toBe(computePermissionKey(user))
})
test('computePermissionKey matches the documented blob hash', () => {
const user: MintUser = { pk: 1, isStaff: true, isSuperuser: false, permissions: ['z', 'a'] }
// "1:0:a,z" — staff:super:sorted-perms.
const expected = createHash('sha256').update('1:0:a,z', 'utf-8').digest('hex')
expect(computePermissionKey(user)).toBe(expected)
})
})
describe('MWT mint — cross-language pin (Python create_mwt)', () => {
const SECRET = 'pin-mint-secret-mwt'
const NOW = 1700000000
test.skipIf(!UV_AVAILABLE)('TS signMwt byte-identical to Python create_mwt', () => {
const user: MintUser = {
pk: 42,
isStaff: true,
isSuperuser: false,
permissions: ['app.view_thing', 'app.change_thing'],
}
const tsToken = signMwt(user, SECRET, { ttl: 300, now: NOW })
// Drive the REAL create_mwt with time.time pinned to NOW and a
// user stub whose get_all_permissions returns the same perms.
const pyToken = py(String.raw`
import time, sys
time.time = lambda: ${NOW}
from mizan_core.mwt import create_mwt
class U:
pk = 42
is_staff = True
is_superuser = False
def get_all_permissions(self):
return {"app.view_thing", "app.change_thing"}
sys.stdout.write(create_mwt(U(), ${JSON.stringify(SECRET)}, ttl=300))
`)
expect(tsToken).toBe(pyToken)
})
})
describe('JWT mint — cross-language pin (Python create_access/refresh_token)', () => {
const SECRET = 'pin-mint-secret-jwt'
const NOW = 1700000000
const config = { privateKey: SECRET, accessTokenExpiresIn: 300, refreshTokenExpiresIn: 604800 }
const claims = { userId: 42, sessionKey: 'sess-abc', isStaff: true, isSuperuser: false }
test.skipIf(!UV_AVAILABLE)('TS createAccessToken byte-identical to Python', () => {
const tsToken = createAccessToken(claims, config, NOW)
const pyToken = py(String.raw`
import time, sys
time.time = lambda: ${NOW}
from mizan_core.auth.jwt import JWTConfig, create_access_token
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
sys.stdout.write(create_access_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
`)
expect(tsToken).toBe(pyToken)
})
test.skipIf(!UV_AVAILABLE)('TS createRefreshToken byte-identical to Python', () => {
const tsToken = createRefreshToken(claims, config, NOW)
const pyToken = py(String.raw`
import time, sys
time.time = lambda: ${NOW}
from mizan_core.auth.jwt import JWTConfig, create_refresh_token
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
sys.stdout.write(create_refresh_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
`)
expect(tsToken).toBe(pyToken)
})
})

View File

@@ -0,0 +1,131 @@
/**
* Session-init + WebSocket transport tests.
*
* session-init returns the `{ csrfToken }` no-store shape at parity with the
* Django/FastAPI/Axum session endpoint. The WebSocket transport drives the
* SAME dispatch core the HTTP path uses, so a function exposed over WS behaves
* identically — invalidation, auth, and not-found all carry through.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import {
ReactContext,
client,
clearRegistry,
handleSessionInit,
sessionInitRoute,
SESSION_INIT_PATH,
handleWebSocketMessage,
serveWebSocket,
type Identity,
type WebSocketLike,
} from '../src'
describe('session-init', () => {
test('returns { csrfToken: null } with no-store', () => {
const r = handleSessionInit()
expect(r.status).toBe(200)
expect(r.body).toEqual({ csrfToken: null })
expect(r.headers['Cache-Control']).toBe('no-store')
expect(r.headers['Content-Type']).toBe('application/json')
})
test('embeds a host-provided CSRF token', () => {
const r = handleSessionInit('tok-123')
expect(r.body).toEqual({ csrfToken: 'tok-123' })
})
test('route descriptor mounts GET /session/ (parity with Django/FastAPI/Axum)', () => {
expect(SESSION_INIT_PATH).toBe('/session/')
expect(sessionInitRoute.path).toBe('/session/')
expect(sessionInitRoute.method).toBe('GET')
// The wired handler returns the session shape.
expect(sessionInitRoute.handler().body).toEqual({ csrfToken: null })
})
})
describe('WebSocket transport', () => {
beforeEach(() => clearRegistry())
const UserCtx = new ReactContext('user')
function setup() {
client({ context: UserCtx, websocket: true }, async function user_profile(user_id: number) {
return { user_id, name: `user_${user_id}` }
})
client({ affects: UserCtx, websocket: true }, async function update_profile(user_id: number, name: string) {
return { ok: true, user_id, name }
})
}
test('call frame routes through mutation dispatch + carries invalidation', async () => {
setup()
const reply = await handleWebSocketMessage({
id: 1,
type: 'call',
fn: 'update_profile',
args: { user_id: 5, name: 'X' },
})
expect(reply.id).toBe(1)
expect(reply.result).toEqual({ ok: true, user_id: 5, name: 'X' })
expect(reply.invalidate).toBeDefined()
expect(reply.invalidate[0].context).toBe('user')
expect(reply.invalidate[0].params.user_id).toBe(5)
})
test('fetch frame routes through context bundle', async () => {
setup()
const reply = await handleWebSocketMessage({
id: 2,
type: 'fetch',
context: 'user',
params: { user_id: '7' },
})
expect(reply.id).toBe(2)
expect(reply.result.user_profile).toEqual({ user_id: '7', name: 'user_7' })
})
test('unknown function returns an error frame, not a throw', async () => {
const reply = await handleWebSocketMessage({ id: 3, type: 'call', fn: 'nope' })
expect(reply.error).toBeDefined()
expect(reply.error!.code).toBe('NOT_FOUND')
expect(reply.id).toBe(3)
})
test('auth enforcement carries over the WS transport', async () => {
client({ auth: true, websocket: true }, async function secret() {
return { ok: true }
})
const anon: Identity = { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
const reply = await handleWebSocketMessage({ id: 4, type: 'call', fn: 'secret' }, anon)
expect(reply.error!.code).toBe('UNAUTHORIZED')
})
test('malformed JSON frame → error', async () => {
const reply = await handleWebSocketMessage('{not json')
expect(reply.error!.code).toBe('BAD_REQUEST')
})
test('serveWebSocket wires a connection and replies as JSON', async () => {
setup()
const sent: string[] = []
let listener: ((e: { data: any }) => void) | null = null
const ws: WebSocketLike = {
send: (d) => sent.push(d),
addEventListener: (_t, l) => {
listener = l
},
}
serveWebSocket(ws)
expect(listener).not.toBeNull()
// Drive a message through the wired listener.
await listener!({ data: JSON.stringify({ id: 9, type: 'fetch', context: 'user', params: { user_id: '3' } }) })
// Give the async handler a tick to resolve + send.
await new Promise((r) => setTimeout(r, 0))
expect(sent.length).toBe(1)
const reply = JSON.parse(sent[0])
expect(reply.id).toBe(9)
expect(reply.result.user_profile.name).toBe('user_3')
})
})

View File

@@ -0,0 +1,163 @@
/**
* Upload tests — multipart File-part binding + constraint enforcement.
*
* Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts
* into the function's Upload-typed inputs, and `File(...)` constraints
* (max-size, content-type) reject at dispatch with a 400.
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import {
client,
clearRegistry,
handleMultipartCall,
parseSize,
validateUpload,
UploadedFile,
type StructField,
} from '../src'
const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => {
let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes }
if (opts.list) shape = { kind: 'list', inner: shape }
if (opts.optional) shape = { kind: 'optional', inner: shape }
return { name, required: !opts.optional, shape }
}
const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } })
function multipart(fn: string, args: Record<string, any>, files: Record<string, Blob | Blob[]>): FormData {
const form = new FormData()
form.set('fn', fn)
form.set('args', JSON.stringify(args))
for (const [key, val] of Object.entries(files)) {
for (const f of Array.isArray(val) ? val : [val]) form.append(key, f)
}
return form
}
describe('parseSize', () => {
test('parses human sizes', () => {
expect(parseSize('5MB')).toBe(5 * 1024 * 1024)
expect(parseSize('1KB')).toBe(1024)
expect(parseSize('2GB')).toBe(2 * 1024 ** 3)
expect(parseSize(123)).toBe(123)
expect(parseSize('500')).toBe(500)
})
})
describe('validateUpload', () => {
test('max-size rejection', () => {
const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100))
expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size')
expect(validateUpload(f, { maxSize: 200 })).toBeNull()
})
test('content-type allowlist + wildcard', () => {
const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1))
expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull()
expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull()
expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed')
})
})
describe('multipart dispatch', () => {
beforeEach(() => clearRegistry())
test('binds a file part into the Upload input', async () => {
let received: UploadedFile | null = null
client(
{
affects: 'avatars',
ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] },
},
async function set_avatar(user_id: number, avatar: UploadedFile) {
received = avatar
return { ok: true, name: avatar.filename, bytes: avatar.size }
},
)
const form = multipart('set_avatar', { user_id: 5 }, {
avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }),
})
const r = await handleMultipartCall(form)
expect(r.status).toBe(200)
expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 })
expect(received).not.toBeNull()
expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4]))
})
test('max-size violation rejects with 400', async () => {
client(
{ affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } },
async function set_avatar(_avatar: UploadedFile) {
return { ok: true }
},
)
const form = multipart('set_avatar', {}, {
avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }),
})
const r = await handleMultipartCall(form)
expect(r.status).toBe(400)
expect(r.body.message).toContain('avatar:')
expect(r.body.message).toContain('exceeds max size')
})
test('content-type violation rejects with 400', async () => {
client(
{ affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } },
async function set_avatar(_avatar: UploadedFile) {
return { ok: true }
},
)
const form = multipart('set_avatar', {}, {
avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }),
})
const r = await handleMultipartCall(form)
expect(r.status).toBe(400)
expect(r.body.message).toContain('not allowed')
})
test('list upload binds multiple parts', async () => {
let count = 0
client(
{ affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } },
async function add_photos(photos: UploadedFile[]) {
count = photos.length
return { ok: true, count: photos.length }
},
)
const form = multipart('add_photos', {}, {
photos: [
new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }),
new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }),
],
})
const r = await handleMultipartCall(form)
expect(r.status).toBe(200)
expect(r.body.result.count).toBe(2)
expect(count).toBe(2)
})
test('missing fn → 400', async () => {
const form = new FormData()
form.set('args', '{}')
const r = await handleMultipartCall(form)
expect(r.status).toBe(400)
expect(r.body.message).toContain("'fn'")
})
test('invalidation still emitted on multipart mutation', async () => {
client(
{ affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } },
async function set_avatar(_user_id: number, _avatar: UploadedFile) {
return { ok: true }
},
)
const form = multipart('set_avatar', { user_id: 9 }, {
avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }),
})
const r = await handleMultipartCall(form)
expect(r.status).toBe(200)
expect(r.headers['X-Mizan-Invalidate']).toContain('avatars')
})
})

View File

@@ -0,0 +1,161 @@
"""
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
)

View File

@@ -5,12 +5,11 @@ sub-registry (channels/WebSocket, forms, shapes) 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, and registers it here so the unified schema export
sees it. Django binds all of them today; the other adapters' unbound
extensions are gaps tracked by the capability-parity suite in
`tests/afi/`, not framework-specific features. The binding is per-stack
(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the
capability is common.
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.
"""
from __future__ import annotations

View File

@@ -0,0 +1,12 @@
"""
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"]

View File

@@ -1,5 +1,10 @@
"""
SSR Bridge Manages a persistent Bun subprocess for React rendering.
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.
@@ -33,7 +38,7 @@ class SSRBridge:
"""
Manages a persistent Bun subprocess for server-side rendering.
Thread-safe. Multiple Django workers can call render() concurrently.
Thread-safe. Multiple worker threads can call render() concurrently.
Request-response matching via message IDs.
"""

View File

@@ -26,6 +26,15 @@ 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 {
@@ -45,10 +54,16 @@ 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",
"unknown attribute key; expected one of: context, affects, merge, auth, form_name, form_role",
));
}
}
@@ -57,10 +72,12 @@ 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` or `private`",
"unknown flag; expected `websocket`, `private`, or `auth`",
));
}
}
@@ -99,6 +116,21 @@ 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)?]),
@@ -183,7 +215,11 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
});
}
quote! {
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
// 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)]
pub struct #input_type_ident {
#(#field_defs)*
}
@@ -353,6 +389,20 @@ 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,
@@ -389,6 +439,10 @@ 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>(

View File

@@ -105,6 +105,15 @@ 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) }
@@ -149,6 +158,19 @@ 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> {

View File

@@ -13,12 +13,82 @@ 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"
@@ -34,6 +104,12 @@ 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"
@@ -65,11 +141,14 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"hmac",
"indoc",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -149,6 +228,23 @@ 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"
@@ -160,12 +256,24 @@ 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"

View File

@@ -11,6 +11,9 @@ 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"

View File

@@ -0,0 +1,552 @@
//! 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()))
}
}
}
}

View File

@@ -0,0 +1,272 @@
//! 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, &params_tree, user_id, 0) {
backend.delete(&key);
}
}
}
}
}
}

View File

@@ -26,6 +26,14 @@ 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)]

View File

@@ -160,6 +160,29 @@ 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);
}
}
}
}
@@ -464,7 +487,7 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
walk_shape_refs(b, visit);
}
}
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {}
}
}

View File

@@ -14,25 +14,43 @@
//! 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, InvalidationTarget, MergeEntry, MizanError,
RequestHandle,
compute_invalidation, compute_merges, format_invalidate_header, 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.

View File

@@ -0,0 +1,190 @@
//! 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(),
}
}

View File

@@ -135,6 +135,75 @@ 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 = &params[*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)]

View File

@@ -0,0 +1,146 @@
//! 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())
}

268
cores/mizan-rust/src/ssr.rs Normal file
View File

@@ -0,0 +1,268 @@
//! 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();
}
}

View File

@@ -53,6 +53,12 @@ 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
}

View File

@@ -0,0 +1,72 @@
//! 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,
})
}
}

View File

@@ -0,0 +1,120 @@
//! 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, &params, uid, rev);
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
let params_json = serde_json::to_string(
&params.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}",
);
}
}

View File

@@ -0,0 +1,90 @@
//! 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}");
}
}

View File

@@ -0,0 +1,89 @@
//! 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"])
);
}

View File

@@ -0,0 +1,105 @@
//! 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);
}

View File

@@ -0,0 +1,153 @@
//! 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);
}

View File

@@ -294,8 +294,11 @@ def _probe_websocket(a: Adapter) -> ProbeResult:
def _probe_ssr_bridge(a: Adapter) -> ProbeResult:
if a.id == "django":
return _wired(_has_path(a.id, "ssr", "bridge.py"), "Bun SSR subprocess bridge")
# Uniform, location-independent: the SSR subprocess bridge is single-sourced
# (Python adapters ride `mizan_core.ssr.SSRBridge`); a capability "pass" means
# the ADAPTER invokes it — references the bridge / its renderer — over its own
# surface, not that a `bridge.py` lives at a fixed path. So the check is the
# same for every adapter: an invocation of the SSR renderer in adapter source.
return _wired(_hit(_adapter(a), r"SSRBridge|renderToString|ssr_bridge"), "SSR bridge (subprocess renderer)")

View File

@@ -39,6 +39,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
@@ -50,6 +51,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@@ -57,8 +59,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -86,12 +90,33 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[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 = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -104,6 +129,51 @@ 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 = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[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 = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -138,6 +208,23 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
@@ -151,17 +238,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[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 = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[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 = "http"
version = "1.4.0"
@@ -323,7 +442,10 @@ name = "mizan-axum"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"futures-util",
"mizan-core",
"multer",
"serde",
"serde_json",
"tokio",
@@ -336,10 +458,13 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"hmac",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -352,6 +477,23 @@ dependencies = [
"syn",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -393,6 +535,15 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -411,6 +562,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -504,6 +685,28 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[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 = "signal-hook-registry"
version = "1.4.8"
@@ -536,6 +739,18 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[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"
@@ -553,6 +768,26 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
@@ -581,6 +816,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -645,12 +892,48 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -672,6 +955,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"