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

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