Restore approved state (tree of 4effcc7 "Added LICENSE")

Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.

Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 14:59:53 -04:00
parent adcc027894
commit ae684a36cb
126 changed files with 1711 additions and 13265 deletions

View File

@@ -1,5 +1,4 @@
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the
//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic.
//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC.
//!
//! Ships as a Tauri plugin. The consumer installs it with one line:
//!
@@ -10,137 +9,79 @@
//! .expect("error while running tauri application");
//! ```
//!
//! The plugin exposes commands reachable from the JS-side
//! `@mizan/tauri-transport`:
//! 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.
//!
//! * `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):
//! Wire envelope:
//!
//! ```json
//! { "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": {} }
//! { "op": "call", "fn": "list_sessions", "args": {} }
//! { "op": "fetch", "context": "session", "params": {} }
//! ```
//!
//! Response shapes mirror the HTTP adapter:
//! Response shapes mirror POST /call/ and GET /ctx/.../ from
//! mizan-rust-axum:
//!
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//! * `shape` → `{ type, fields }`
//! * `form` → the form function's result
//! * `call` → `{ result, invalidate, merge? }`
//! * `fetch` → `{ <fnName>: <result>, ... }` (a flat bundle)
//!
//! 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};
//! 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.
use mizan_core::{
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,
compute_invalidation, compute_merges, lookup_context, lookup_function,
FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::ipc::Channel;
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
Runtime,
};
/// 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`.
/// 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`.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("mizan")
.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(())
})
.invoke_handler(tauri::generate_handler![mizan_invoke])
.build()
}
// === Wire envelope ===
/// One Mizan request. Tauri's serde deserializer pulls this out of the
/// `envelope` field of the invoke payload.
/// 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.
#[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.
/// `{"code", "message", "details?"}` shape; the TS-side transport reads
/// this and constructs a `MizanError`.
#[derive(Debug, Serialize)]
pub struct ErrorPayload {
pub code: &'static str,
@@ -164,336 +105,110 @@ impl From<MizanError> for ErrorPayload {
}
}
// === Auth ===
// === Dispatch ===
/// 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.
/// 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.
#[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,
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,
} => handle_call(&app, &function_name, args).await,
Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await,
}
}
async fn handle_call<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
fn_name: &str,
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)?;
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"
)))
})?;
let req = RequestHandle::new(app);
let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?;
let result = fn_spec
.dispatch(req, Value::Object(args.clone()))
.await
.map_err(ErrorPayload::from)?;
let targets = compute_invalidation(fn_spec, &args);
let invalidate: Vec<Value> = targets.iter().map(InvalidationTarget::to_json).collect();
let invalidate: Vec<Value> = compute_invalidation(fn_spec, &args)
.iter()
.map(InvalidationTarget::to_json)
.collect();
let merges = compute_merges(fn_spec, &args, &result);
let merge_payload: Option<Vec<Value>> = if merges.is_empty() {
None
} else {
Some(merges.iter().map(MergeEntry::to_json).collect())
};
// Purge the origin cache for everything this mutation invalidated.
if !targets.is_empty() {
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()),
);
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));
}
Ok(payload)
}
async fn handle_fetch<R: Runtime>(
app: &tauri::AppHandle<R>,
cfg: &MizanTauriConfig,
context_name: &str,
params: Map<String, Value>,
token: Option<&str>,
) -> Result<Value, MizanError> {
let identity = identity_from_token(token, cfg)?;
) -> Result<Value, ErrorPayload> {
if lookup_context(context_name).is_none() {
return Err(MizanError::NotFound(format!(
return Err(ErrorPayload::from(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(MizanError::NotFound(format!(
return Err(ErrorPayload::from(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?;
let result = fn_spec
.dispatch(req, Value::Object(args))
.await
.map_err(ErrorPayload::from)?;
bundled.insert(fn_spec.name().to_string(), result);
}
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)
Ok(Value::Object(bundled))
}
/// `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.
/// 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.
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() {
@@ -503,45 +218,3 @@ 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(())
}