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:
101
backends/mizan-tauri/Cargo.lock
generated
101
backends/mizan-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, ¶ms);
|
||||
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(())
|
||||
}
|
||||
|
||||
67
backends/mizan-tauri/src/ssr.rs
Normal file
67
backends/mizan-tauri/src/ssr.rs
Normal 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 })
|
||||
}
|
||||
370
backends/mizan-tauri/tests/behavior.rs
Normal file
370
backends/mizan-tauri/tests/behavior.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user