Compare commits

..

5 Commits

Author SHA1 Message Date
587be8c4ab SSR migrated to Rust 2026-06-04 21:37:24 -04:00
ae684a36cb 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>
2026-06-04 14:59:53 -04:00
adcc027894 Deleted hallucination docs 2026-06-04 14:28:30 -04:00
6c5f6f1fba 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>
2026-06-04 13:44:35 -04:00
58d2cb2848 AFI parity: generate the matrix from conformance probes, not prose
The per-adapter parity table was hand-maintained prose. An adapter that
never wired a capability (FastAPI SSR, Axum WebSocket) got its gap
relabelled "Django-only" or "out of scope — use native equivalents," and
nothing went red. The de-scope was crystallized in five mutually-ratifying
sites: the README §Stack-extensions table, the AFI fixture docstring
("channels/forms/shapes aren't AFI-common"), the core registry's
extension-hook framing, the mizan-fastapi __init__ docstring, and a
"CSRF is Django-only" comment in two adapters' session endpoints.

Replace prose-parity with conformance-generated parity:

- tests/afi/manifest.py declares the AFI-common surface as data — one list
  of capabilities, one of adapters. Applicability ("—") is derived from
  transport, never typed.
- tests/afi/probes.py independently inspects each backend's source for the
  artifact a capability requires (comment-stripped, backend-scoped). Green
  means wired; a cell can't be set by editing a word.
- tests/afi/test_capability_parity.py asserts every (capability × applicable
  adapter) pair is wired. 35 unwired gaps are now loud red TFDD tests, each
  naming an owed binding. No xfail/skip.
- tests/afi/parity_table.py generates the README table from the probes;
  `make parity-check` fails CI on any hand-edit, like the codegen byte-parity.

Purge the five de-scope sites. The IR byte-parity gate is unchanged and green.
`make test-afi` is now intentionally red on the 35 gaps — that board is the
owed parity work, itemized; a gap turns green by being wired, never described.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:58:03 -04:00
60 changed files with 3585 additions and 3007 deletions

View File

@@ -1,4 +1,4 @@
name: Publish Django package to Gitea registry name: Publish Django package to PyPI
on: on:
push: push:

View File

@@ -1,4 +1,4 @@
name: Publish React package to Gitea registry name: Publish React package to npm
on: on:
push: push:

3
.gitignore vendored
View File

@@ -34,6 +34,3 @@ examples/django-react-site/harness/test-results/
.env.* .env.*
*.pem *.pem
*.key *.key
# Agent worktrees (transient scratch — never tracked)
.claude/worktrees/

473
CLAUDE.md
View File

@@ -1,473 +0,0 @@
# Mizan — Technical Reference
## What Mizan Is
Mizan is an Application Framework Interface (AFI). One decorator on a server function. Typed client generated. Invalidation automatic. Caching protocol-driven. SSR via subprocess.
Django + React ships first. The protocol is language-agnostic (proven by mizan-ts).
---
## Package Layout
Tree organized by role. Per-framework adapters wrap a single shared kernel; codegen targets the adapter.
```
backends/ server protocol adapters
mizan-django/ Django adapter
mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope)
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
mizan-rust-axum/ Rust/Axum adapter (server-side substrate; three-way parity)
mizan-tauri/ Tauri-as-Mizan-backend substrate
frontends/ client kernel + per-framework adapters + transports
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
mizan-react/ React contexts + hooks over the kernel
mizan-vue/ Vue composables over the kernel (codegen target; runtime package unimplemented)
mizan-svelte/ Svelte stores over the kernel (codegen target; runtime package unimplemented)
mizan-rust/ Rust kernel (PyO3 bridge; consumed by the Rust codegen's python target)
mizan-tauri-transport/ Tauri IPC transport for the kernel
mizan-webview-transport/ VSCode-webview transport for the kernel
mizan-webview-channels/ webview channel transport
cores/ shared language-level primitives
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
mizan-rust/ shared Rust primitives (IR, KDL, registry, graph-check)
mizan-rust-macros/ proc-macros for the Rust backend/kernel
protocol/ protocol-level tooling
mizan-codegen/ the codegen — a Rust binary; reads KDL IR, emits typed clients per target
mizan-generate/ thin npm launcher around the compiled mizan-codegen binary
workers/ runtime workers / bridges
mizan-ssr/ Bun subprocess used by the Django template backend
```
---
## The Three Protocols
### 1. RPC Protocol (Anti-REST)
No resources. No CRUD. Functions in, results out.
**Context fetch (reads):**
```
GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2
200 OK
Cache-Control: no-store
Content-Type: application/json
{
"user_profile": {"name": "Ryth", "email": "ryth@example.com"},
"user_orders": [{"id": 1, "total": 100}]
}
```
All functions sharing a context name are bundled into one response. Keys are function names. Values are return values.
**Mutation call (writes):**
```
POST /api/mizan/call/
Content-Type: application/json
{"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}
200 OK
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5
{
"result": {"ok": true},
"invalidate": [{"context": "user", "params": {"user_id": 5}}]
}
```
### 2. Invalidation-on-Mutation Protocol
Two transports for the same signal. Both are first-class.
**Transport 1 — JSON body** (for RPC/SPA clients):
```json
{"result": {...}, "invalidate": ["user"]}
{"result": {...}, "invalidate": [{"context": "user", "params": {"user_id": 5}}]}
```
**Transport 2 — HTTP header** (for Edge, htmx, view-path functions):
```
X-Mizan-Invalidate: user
X-Mizan-Invalidate: user;user_id=5
X-Mizan-Invalidate: user;user_id=5, notifications
```
Format: comma-separated contexts, semicolon-separated URL-encoded params per context.
**Three-tier auto-scoping** (no developer annotation needed):
1. **Argument name matching:** mutation has `user_id` param, context has `user_id` param → scoped automatically
2. **Auth inference:** Edge-side concern (reads JWT/MWT to extract user identity)
3. **Broad fallback:** invalidate all instances of the context
**Return-type branching** determines which transport:
- Function returns data (dict, BaseModel) → RPC path → JSON body + header
- Function returns HttpResponse (redirect, HTML) → View path → header only
### 3. Frontend-Agnostic Rendering (SSR + PSR)
**SSR** — Django template backend integration. `render(request, 'components/Hello.tsx', props)` — the template name is a `.tsx`/`.jsx` **file path** (resolved against `DIRS`), not a component name — calls a persistent Bun subprocess that runs `renderToString`.
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
**The Bun worker protocol** — JSON-RPC over stdin/stdout. The worker `import()`s the file and renders it (no component registry):
```
→ {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {"name": "World"}}}
← {"id": 1, "html": "<div>...</div>"}
```
Worker stays alive across requests. Django's `SSRBridge` manages the subprocess lifecycle with thread-safe request correlation via message IDs.
---
## The @client Decorator — Full API
```python
from mizan import client, ReactContext, GlobalContext
UserContext = ReactContext('user')
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `context` | `ReactContext \| str \| False` | `False` | Named context for grouping. `False` = standalone function. |
| `affects` | `ReactContext \| str \| list` | `None` | What this mutation invalidates. Mutually exclusive with `context`. |
| `private` | `bool` | `False` | Not client-callable. No RPC endpoint. No codegen. Still in invalidation graph. |
| `route` | `str \| None` | `None` | Mizan-owned URL pattern for view-path functions. |
| `methods` | `list[str] \| None` | `None` | HTTP methods for route. Default: `['GET']` for context, `['POST']` for mutation. |
| `auth` | `bool \| str \| callable \| None` | `None` | Auth requirement: `True`, `'staff'`, `'superuser'`, or `callable(request) -> bool`. |
| `websocket` | `bool` | `False` | Enable WebSocket RPC transport. |
| `rev` | `int` | `0` | Cache revision. Increment to bust cached entries on deploy. |
| `cache` | `int \| False` | (default) | Cache TTL hint. `False` = never cache. Integer = TTL seconds. |
### Usage Patterns
```python
# Global context — auto-mounted at root, SSR-hydrated
@client(context=GlobalContext)
def current_user(request) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
# Named context — bundled GET, generates typed hooks
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape:
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
@client(context=UserContext)
def user_orders(request, user_id: int) -> list[OrderShape]:
return OrderShape.query(lambda qs: qs.filter(user_id=user_id))
# Mutation — auto-scoped invalidation (user_id matches)
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict:
request.user.name = name
request.user.save()
return {"ok": True}
# Function-level affects — only user_profile refetches
@client(affects='user_profile')
def update_name(request, user_id: int, name: str) -> dict:
...
# View-path context — registered in invalidation graph, no codegen
@client(context=UserContext, route='/profile/<user_id>/')
def profile_page(request, user_id: int) -> HttpResponse:
return render(request, 'profile.html', {...})
# View-path mutation — invalidation via header on the redirect
@client(affects=UserContext, route='/profile/<user_id>/update/', methods=['POST'])
def update_profile_view(request, user_id: int) -> HttpResponse:
form = ProfileForm(request.POST)
if form.is_valid():
form.save()
return redirect(f'/profile/{user_id}/')
# Private webhook — not client-callable, emits invalidation
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
def stripe_webhook(request) -> HttpResponse:
event = json.loads(request.body)
process_stripe_event(event)
return HttpResponse(status=200)
# Auth guards
@client(auth=True)
def secret(request) -> dict: ...
@client(auth='staff')
def admin_action(request) -> dict: ...
@client(auth=lambda req: req.user.email.endswith('@company.com'))
def internal_tool(request) -> dict: ...
```
### _meta Dict Structure
After decoration, the function class has `_meta` with these possible keys:
```python
{
"context": "user", # context name string (if context=)
"affects": [ # normalized affects targets (if affects=)
{"type": "context", "name": "user"},
{"type": "function", "name": "user_profile", "context": "user"},
],
"private": True, # if private=True
"route": "/webhooks/stripe/", # if route=
"methods": ["POST"], # if route= (defaults applied)
"view_path": True, # if return type is HttpResponse
"websocket": True, # if websocket=True
"auth": "required", # "required" | "staff" | "superuser" | callable
"rev": 3, # if rev=
"cache": 60, # if cache=
"form": True, # if form function
"form_name": "contact", # form name
"form_role": "schema", # "schema" | "validate" | "submit"
}
```
---
## Cache System
### Required Settings
```python
# settings.py
MIZAN_CACHE_SECRET = "your-32-byte-hmac-signing-key" # Required for cache
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0" # Required for cache
```
Both must be set. If either is missing, caching is disabled with a warning.
### HMAC Key Derivation
Cache keys are derived from HMAC-SHA256 over a JSON-canonical form:
```python
derive_cache_key(secret, context, params, user_id=None, rev=0) -> str
```
**Canonical form** (the HMAC message):
```json
{"c":"user","p":{"user_id":"5"},"r":0}
```
With optional `"u":"5"` for user-scoped entries.
- `c` = context name
- `p` = sorted params dict (all values stringified)
- `r` = revision number
- `u` = user ID (for auth-scoped cache entries)
**Key format:** `ctx:{context}:{hmac_hex}`
- Example: `ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6`
**Cross-language conformance:** The TypeScript adapter (`mizan-ts/src/cache/keys.ts`) produces identical keys for identical inputs. Pin tests verify this.
### Cache Operations
```python
from mizan.cache import cache_get, cache_put, cache_purge
# Store
cache_put(secret, backend, "user", {"user_id": "5"}, b'{"name":"Ryth"}')
# Retrieve
data = cache_get(secret, backend, "user", {"user_id": "5"})
# Scoped purge (recomputes HMAC, deletes one key)
cache_purge(backend, "user", params={"user_id": "5"}, secret=secret)
# Broad purge (SCAN by prefix "ctx:user:*")
cache_purge(backend, "user")
```
### Backends
**MemoryCache** — dict-based, for testing. No persistence.
**RedisCache** — production backend.
- Connection pooling (50 max connections)
- 24h default TTL safety net
- Key prefix: `mizan:` (configurable)
- `delete_by_prefix` uses Redis SCAN (1000 keys per batch)
- `delete` uses UNLINK (non-blocking)
### Cache Integration in Dispatch
`context_fetch_view` checks origin-side cache before executing functions. On cache miss, executes functions and stores the result. On mutation, purges affected cache entries based on the invalidation targets.
All HTTP responses emit `Cache-Control: no-store`. Origin-side caching is internal — the HTTP layer never caches at the CDN. Edge caching is managed by Mizan Edge (closed-source Cloudflare Workers) which uses the manifest and MWT tokens.
---
## MWT (Mizan Web Token) and JWT
### Two Token Systems
**JWT** — standard user authentication tokens. Access + refresh pair. Session-tied for revocation.
```python
# settings.py
JWT_PRIVATE_KEY = "your-secret-key" # Required
JWT_ALGORITHM = "HS256" # Default, or RS256 for asymmetric
JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
JWT_REFRESH_TOKEN_EXPIRES_IN = 604800 # 7 days
JWT_VALIDATE_SESSION = True # Check session exists on use
```
JWT claims: `sub` (user ID), `sid` (session key), `staff`, `super`, `type` (access/refresh), `iat`, `exp`.
Session validation: on every JWT use, checks that the session still exists. Logging out destroys the session → immediately revokes all tokens tied to it.
**MWT** — Mizan Web Token. Protocol-owned identity for Edge cache keying. Separate secret from JWT and cache.
```python
# settings.py
MIZAN_MWT_SECRET = "your-mwt-signing-key" # Separate from JWT_PRIVATE_KEY
MIZAN_MWT_TTL = 300 # 5 minutes
```
MWT is used by Mizan Edge to derive user-scoped cache keys without exposing the cache secret to the client. The MWT carries claims that Edge needs (user identity, permissions) in a short-lived token that travels on a custom header (`X-Mizan-Token`).
### Secret Separation
Three independent secrets, each with its own blast radius:
| Secret | Setting | Purpose | Compromise Impact |
|--------|---------|---------|-------------------|
| JWT secret | `JWT_PRIVATE_KEY` | User auth tokens | Auth bypass |
| Cache secret | `MIZAN_CACHE_SECRET` | HMAC cache keys | Cache poisoning |
| MWT secret | `MIZAN_MWT_SECRET` | Edge identity tokens | Cache key spoofing |
---
## SSR Implementation
### Django Template Backend
```python
# settings.py
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'DIRS': [BASE_DIR / 'frontend'],
'OPTIONS': {
'worker': 'path/to/mizan-ssr/src/worker.tsx',
'timeout': 5,
},
},
]
```
### Usage in Views
```python
from django.shortcuts import render
def profile_page(request, user_id):
profile = get_user_profile(user_id)
return render(request, 'components/Profile.tsx', {'profile': profile})
```
`render()` calls `MizanTemplates.get_template('components/Profile.tsx')` — the name is a file path resolved to an absolute path against `DIRS` — which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC (`{file, props}`) to the Bun worker.
### SSR Bridge (bridge.py)
- Spawns `bun run <worker>` on first render
- Persistent subprocess — stays alive across requests
- JSON-RPC over stdin/stdout with message ID correlation
- Thread-safe: multiple Django workers can call `render()` concurrently
- Auto-restarts on crash
- Waits for `{"id": 0, "ready": true}` before accepting requests
### Bun Worker (worker.tsx)
- Reads newline-delimited JSON from stdin
- Resolves the component by **file path**`import(file)` (cached) — no registry
- Calls `renderToString(createElement(Component, props))` on the imported default export
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
- Health check: `{"method": "ping"}``{"pong": true}`
---
## Edge Manifest
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
```json
{
"contexts": {
"user": {
"functions": [
{"name": "user_profile", "path": "rpc"},
{"name": "profile_page", "path": "view", "route": "/profile/<user_id>/"}
],
"endpoints": ["/api/mizan/ctx/user/"],
"params": ["user_id"],
"user_scoped": true,
"render_strategy": "dynamic_cached",
"page_routes": ["/profile/<user_id>/"]
}
},
"mutations": {
"update_profile": {
"affects": ["user"],
"auto_scoped_params": ["user_id"]
},
"stripe_webhook": {
"affects": ["subscription"],
"private": true,
"route": "/webhooks/stripe/",
"methods": ["POST"]
}
}
}
```
**render_strategy**: `"psr"` (no user-scoped params) or `"dynamic_cached"` (user-scoped). Derived automatically from whether params overlap with `{user_id, user, owner_id, account_id}`.
---
## URL Patterns
```python
# mizan/urls.py
urlpatterns = [
path("session/", session_init_view), # GET — CSRF cookie
path("call/", function_call_view), # POST — RPC dispatch
path("ctx/<str:context_name>/", context_fetch_view), # GET — bundled context fetch
]
```
Mounted at `/api/mizan/` by convention:
```python
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]
```
---
## Codegen — Current State
The codegen is a **Rust binary**, `protocol/mizan-codegen/` (crate `mizan-codegen`). `protocol/mizan-generate/` is a thin npm launcher (`bin/launcher.mjs`) that shells out to the compiled binary. The IR is **KDL** — each backend emits KDL describing its functions/contexts; the binary reads it (`src/fetch.rs`, `src/ir.rs`) and emits per-target output from Askama templates (`templates/`, dispatched in `src/emit/`).
Two layers, same as before: a framework-agnostic protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types) and a per-framework adapter layer that subscribes to the `mizan-base` kernel.
**Targets** (`src/emit/`, each byte-checked by a `*_parity.rs` test):
- `react` — function/context hooks over `useSyncExternalStore`, plus the full wrapper layer: the `MizanContext` root provider (calls `configure()`, mounts the global context), `useMizan()` imperative escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`.
- `vue`, `svelte` — composables / `readable` stores. Byte-parity-tested, but no runtime adapter package or live-backend example exercises them yet (the `mizan-vue`/`mizan-svelte` packages are unimplemented stubs).
- `channels` — WebSocket transport hooks.
- `stage1` — the framework-agnostic protocol files.
- `python`, `rust` — typed clients for the Python (PyO3) and Rust frontends.
The pre-kernel `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) still ships and is imported by the desktop example; it coexists with the generated `MizanContext`. Forms (`mizan-react/src/forms.ts`) are hand-written and consume the pre-kernel provider — a form codegen target wired to `mizanCall` is still owed. See `ISSUES.md`.
The SSR pipeline is independent of the codegen — the Bun worker resolves a component by **file path** (`import(file)` + `renderToString`), not via a registry.

107
INVARIANTS.md Normal file
View File

@@ -0,0 +1,107 @@
# Application Framework Interface Invariants
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
## Backend Adapters
Django (python)
FastAPI (python)
Typescript (generic)
Rust/Axum (generic)
Tauri (Rust)
## Frontend Adapters
React (Typescript)
Vue (Typescript)
Svelte (Typescript)
Tauri (Rust)
### Client Function RPC
---
No REST endpoints.
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
### WebSocket Support
---
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
### Named Contexts
---
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
### Mutation Invalidation
---
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
### API Shapes
---
A backend adapter supports the "API Shape" feature to the fullest extent:
- ORM Integration
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
### Auth
---
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
### File Uploads
---
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
### Canonical IR & Codegen
---
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
### Client Kernel
---
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
### SSR
---
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
## Compositions
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.

View File

@@ -11,9 +11,8 @@ no longer exist.
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification. - [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom. - [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed. - [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
- [ ] **Upload dispatch not wired for Rust/Axum + Tauri.** The `Upload` type is first-class end to end — IR (`upload` KDL node), codegen (TS `File`; the Rust target lowers it to `Vec<u8>`), kernel (auto-multipart), and dispatch+constraint binding on Django and FastAPI. The Rust/Axum and Tauri *adapters* have no upload concept at dispatch — they don't bind multipart file parts yet.
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it. - [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: cross-language stringification of un-normalized value types, and no thundering-herd / single-flight protection. - [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`. - [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
## Resolved this pass ## Resolved this pass

View File

@@ -52,8 +52,7 @@ The surface every Mizan adapter implements.
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ | | Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | ⁸ | | Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | ⁸ |
| File uploads (multipart, `Upload` type) | ✅ | ✅ | ❌ ⁹ | ❌ ⁹ | — ¹⁰ |
### Edge, cache & enforcement ### Edge, cache & enforcement
@@ -61,18 +60,15 @@ Protocol transports and guarantees co-equal with the body channel in the spec.
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|---|:---:|:---:|:---:|:---:|:---:| |---|:---:|:---:|:---:|:---:|:---:|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | | ❌ | — ¹ | ✅ | | Invalidation — `X-Mizan-Invalidate` header | ✅ | | ❌ | — ¹ | ✅ |
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ✅ ¹¹ | | Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | |
| Origin-side HMAC cache | ✅ | | ❌ | ❌ | ✅ | | Origin-side HMAC cache | ✅ | | ❌ | ❌ | ✅ |
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ | | Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ | | PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ | | Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce > **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
> it — do not rely on `auth=` for access control on those adapters. > it — do not rely on `auth=` for access control on those adapters.
>
> Django, FastAPI, and TypeScript share one auth/invalidation/cache implementation
> (`mizan_core` for the Python adapters; the same spec, pinned cross-language, for TS).
### Stack extensions (Django) ### Stack extensions (Django)
@@ -85,8 +81,8 @@ target stack calls for them.
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ | | Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ | | Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — | | API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
| JWT auth (access / refresh) ¹² | ✅ | ✅ | ❌ | ❌ | ◑ ¹³ | | JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
| MWT (edge identity token) | ✅ | | ❌ | — | ◑ ¹³ | | MWT (edge identity token) | ✅ | | ❌ | — | |
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ | | SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ | | Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
@@ -107,26 +103,9 @@ target stack calls for them.
rather than fetching over HTTP. rather than fetching over HTTP.
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire 7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
parity; CSRF is Django-only. parity; CSRF is Django-only.
8. `mizan-ts` emits the Edge manifest (JSON) but has no KDL IR emitter, so it can't yet 8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
feed the codegen — an unbuilt gap. A TypeScript backend still needs the generated codegen source — it demonstrates the cache + invalidation protocol is
client (types + `callXxx`/`fetchXxx` + framework hooks); same language doesn't remove language-agnostic.
the need for it.
9. The `mizan-codegen` crate parses the `upload` KDL node and emits the field across
targets (the Rust target lowers it to `Vec<u8>`). Multipart dispatch binding is wired
for Django and FastAPI only; the Rust/Axum and Tauri *adapters* have no upload concept
at dispatch yet.
10. The TypeScript column is the `mizan-ts` backend adapter, which has no upload
dispatch. The matching client side lives in the kernel (`@mizan/base`): `mizanCall`
auto-switches to `multipart/form-data` when any argument is a `File`.
11. `mizan-ts` dispatch now enforces `auth=` (`true`/`'staff'`/`'superuser'`/predicate)
against a host-supplied `Identity`, byte-matching the Python guard's denial messages.
12. JWT/MWT token logic is single-sourced in `mizan_core.auth`; Django and FastAPI ride
it. Session-validation (immediate-logout revocation) is Django-only — FastAPI mints
from its own credential check.
13. `mizan-ts` ships an optional `decodeMwt`/`decodeJwtBearer`/`identityFromMwt` helper
(HS256 via Node `crypto`, cross-language pin-tested against a Python-minted MWT) so a
TS edge worker can derive `Identity` from a Python-issued token. Identity source stays
host-supplied; `mizan-ts` does not mint from a session.
## Conformance ## Conformance

View File

@@ -34,11 +34,21 @@
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`. - [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider. - [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired. - [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
- [ ] **Cache hardening**thundering-herd / single-flight protection, and pinning cross-language stringification of un-normalized value types (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`). - [ ] **Cache hardening**purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
- [ ] **Package READMEs**`mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`). - [ ] **Package READMEs**`mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
--- ---
## Core Consolidation — Rust Binary
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
---
## Mizan Cloud (closed-source) ## Mizan Cloud (closed-source)
### Mizan Edge ### Mizan Edge

View File

@@ -89,7 +89,6 @@ from . import setup
from .channels import ReactChannel from .channels import ReactChannel
from .channels import register as register_channel from .channels import register as register_channel
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
from mizan_core.upload import File, Upload, UploadedFile
# Shape is lazy-loaded via __getattr__ because django_readers # Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate() # imports contenttypes, which can't happen during apps.populate()
@@ -165,10 +164,6 @@ __all__ = [
"GlobalContext", "GlobalContext",
"ServerFunction", "ServerFunction",
"ComposedContext", "ComposedContext",
# File uploads
"Upload",
"File",
"UploadedFile",
# Setup # Setup
"mizan_clients", "mizan_clients",
"mizan_module", "mizan_module",

View File

@@ -1,12 +1,16 @@
# Cache Module — Known Issues # Cache Module — Known Issues
Open issues against the current cache implementation. The cache uses Open issues against the current cache implementation. Resolved items are
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key; removed once their fix lands.
broad purge is a prefix SCAN+UNLINK), so there are no index/sub-index races to
track. Resolved items are removed once their fix lands.
## Correctness ## Correctness
### Purge race condition (non-atomic index operations)
`cache_purge` reads the index and deletes as separate operations. A
concurrent `cache_put` between the two steps can orphan entries. Mitigated
by AND-intersection purge semantics, but full atomicity (Lua script or
`WATCH`/`MULTI` on the Redis backend) is still owed.
### Cross-language stringification divergence ### Cross-language stringification divergence
Python `str(True)``"True"` vs JS `String(true)``"true"`. `_normalize` Python `str(True)``"True"` vs JS `String(true)``"true"`. `_normalize`
canonicalizes `True`/`False`/`None` today, but the rules for the remaining canonicalizes `True`/`False`/`None` today, but the rules for the remaining
@@ -15,6 +19,22 @@ TypeScript HMAC keys can still diverge on an un-normalized type.
## Performance / Operability ## Performance / Operability
### Broad purge leaves per-param sub-indexes
A broad `cache_purge(context)` deletes the entries but not the per-param
sub-indexes — a slow Redis memory leak.
### No thundering-herd protection ### No thundering-herd protection
Concurrent cold misses on the same key all execute and write. No Concurrent cold misses on the same key all execute and write. No
single-flight / request-coalescing. single-flight / request-coalescing.
## API shape
### cache_get / cache_put argument inconsistency
`cache_get`/`cache_put` take explicit args while the executor resolves some
inputs from module globals — two access patterns for one concern.
## Coverage
### RedisCache lacks test coverage
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.

View File

@@ -29,10 +29,6 @@ from pydantic import BaseModel, ValidationError
from mizan.cache import get_cache, cache_get, cache_put, cache_purge from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan_core.registry import get_function, get_context_groups from mizan_core.registry import get_function, get_context_groups
from mizan_core.upload import UploadedFile, bind_uploads
from mizan_core import invalidation as _core_inval
from mizan_core.authguard import enforce_auth as _core_enforce_auth
from mizan_core.errors import MizanError as _CoreMizanError
from mizan.setup.settings import get_settings from mizan.setup.settings import get_settings
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -116,14 +112,53 @@ def _check_auth_requirement(
Django User (from session). Either way, no additional DB query is made Django User (from session). Either way, no additional DB query is made
for the built-in checks. Custom callables may query DB if they choose. for the built-in checks. Custom callables may query DB if they choose.
""" """
# Evaluation lives in the shared core (mizan_core.authguard); the callable if auth_requirement is None:
# path receives the native Django request. Core raises; we render to the return None
# Django-shim FunctionError shape the executor expects.
try: user = request.user
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
# Handle callable auth
if callable(auth_requirement):
try:
result = auth_requirement(request)
if result:
return None # Authorized
else:
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Access denied",
)
except PermissionError as e:
# Custom error message from the callable
return FunctionError(
code=ErrorCode.FORBIDDEN,
message=str(e) or "Access denied",
)
# Check authentication (required for all string-based auth)
if not getattr(user, "is_authenticated", False):
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Authentication required",
)
# Check staff requirement
if auth_requirement == "staff":
if not getattr(user, "is_staff", False):
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Staff access required",
)
# Check superuser requirement
elif auth_requirement == "superuser":
if not getattr(user, "is_superuser", False):
return FunctionError(
code=ErrorCode.FORBIDDEN,
message="Superuser access required",
)
return None return None
except _CoreMizanError as e:
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
_cache_log = logging.getLogger("mizan.cache") _cache_log = logging.getLogger("mizan.cache")
@@ -162,6 +197,51 @@ def _purge_cache_for_invalidation(
_cache_log.warning("Cache purge failed", exc_info=True) _cache_log.warning("Cache purge failed", exc_info=True)
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
"""
Determine whether an affects target is a context name or function name.
Returns:
("context", "user", None) — full context invalidation
("function", "user_profile", "user") — function within context
"""
groups = get_context_groups()
# Check if it's a context name directly
if target_name in groups:
return ("context", target_name, None)
# Check if it's a function name within a context
for ctx_name, fn_names in groups.items():
if target_name in fn_names:
return ("function", target_name, ctx_name)
# Not a context or context function — treat as context name anyway
# (it might be a non-context function or an as-yet-unregistered context)
return ("context", target_name, None)
def _get_context_param_names(context_name: str) -> set[str]:
"""
Get the set of parameter names used by functions in a context.
Returns the union of all Input field names across context functions.
"""
groups = get_context_groups()
fn_names = groups.get(context_name, [])
param_names: set[str] = set()
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
param_names.update(input_cls.model_fields.keys())
return param_names
def _resolve_invalidation( def _resolve_invalidation(
view_class: type | None, view_class: type | None,
input_data: dict[str, Any] | None = None, input_data: dict[str, Any] | None = None,
@@ -180,7 +260,49 @@ def _resolve_invalidation(
Returns a list suitable for both JSON body and header serialization. Returns a list suitable for both JSON body and header serialization.
Returns None if no invalidation needed. Returns None if no invalidation needed.
""" """
return _core_inval.resolve_invalidation(view_class, input_data) if view_class is None:
return None
meta = getattr(view_class, "_meta", {})
affects = meta.get("affects")
if not affects:
return None
result = []
seen = set()
for target in affects:
if target["type"] == "context":
target_name = target["name"]
elif target["type"] == "function" and target.get("context"):
# Function-level: use the function name as the invalidation key
target_name = target["name"]
else:
continue
if target_name in seen:
continue
seen.add(target_name)
# Resolve the context this target belongs to (for param lookup)
resolved = _resolve_affects_target(target_name)
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
# Tier 1: argument name matching
if input_data and ctx_for_params:
context_params = _get_context_param_names(ctx_for_params)
matched = {
k: v for k, v in input_data.items()
if k in context_params
}
if matched:
result.append({"context": target_name, "params": matched})
continue
# Tier 3: broad fallback
result.append(target_name)
return result if result else None
def _resolve_merges( def _resolve_merges(
@@ -199,12 +321,94 @@ def _resolve_merges(
Mirrors _resolve_invalidation's tier-1 auto-scoping for params. Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
Entries whose slot can't be uniquely resolved are dropped. Entries whose slot can't be uniquely resolved are dropped.
""" """
return _core_inval.resolve_merges(view_class, input_data, result_data) if view_class is None:
return None
from mizan_core.type_utils import types_match_for_merge
meta = getattr(view_class, "_meta", {})
targets = meta.get("merge") or []
if not targets:
return None
mutation_output = getattr(view_class, "Output", None)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for ctx_name in targets:
if ctx_name in seen:
continue
seen.add(ctx_name)
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
if input_data:
context_params = _get_context_param_names(ctx_name)
matched = {
k: v for k, v in input_data.items()
if k in context_params
}
if matched:
entry["params"] = matched
out.append(entry)
return out
def _format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str: def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core).""" """Find the unique function-name slot in context whose return type matches mutation's output."""
return _core_inval.format_invalidate_header(invalidate) if mutation_output is None:
return None
groups = get_context_groups()
fn_names = groups.get(context_name, [])
matches: list[str] = []
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and type_matcher(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def _format_invalidate_header(
invalidate: list[str | dict[str, Any]],
) -> str:
"""
Format invalidation targets as X-Mizan-Invalidate header value.
Format: comma-separated contexts. Semicolon-separated params per context.
Param values are URL-encoded to prevent delimiter collisions.
Examples:
["user"] → "user"
["user", "notifications"] → "user, notifications"
[{"context": "user", "params": {"user_id": 5}}]
"user;user_id=5"
[{"context": "search", "params": {"q": "hello world"}}]
"search;q=hello%20world"
"""
from urllib.parse import quote
parts = []
for entry in invalidate:
if isinstance(entry, str):
parts.append(entry)
elif isinstance(entry, dict):
ctx = entry["context"]
params = entry.get("params", {})
if params:
param_str = ";".join(
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
for k, v in sorted(params.items())
)
parts.append(f"{ctx};{param_str}")
else:
parts.append(ctx)
return ", ".join(parts)
def execute_function( def execute_function(
@@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
is_multipart = content_type.startswith("multipart/form-data") is_multipart = content_type.startswith("multipart/form-data")
if is_multipart: if is_multipart:
# Multipart carries two shapes: a form submission (Django Form path) or # Multipart form data - used by form submit functions
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
fn_name = request.POST.get("fn") fn_name = request.POST.get("fn")
if not fn_name: if not fn_name:
return FunctionError( return FunctionError(
@@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
message="Missing 'fn' field", message="Missing 'fn' field",
).to_response() ).to_response()
fn_class = get_function(fn_name) # Get form data (excluding 'fn')
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
if is_form_fn:
# Form submit — POST fields + FILES handed to Django Form validation.
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"} input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
# Attach parsed form data and files to request for form functions
request._mizan_form_data = input_data request._mizan_form_data = input_data
request._mizan_form_files = request.FILES request._mizan_form_files = request.FILES
else:
# Upload RPC — the `args` JSON part carries the non-file fields; the
# file parts bind into the Input's Upload fields (constraints enforced).
raw_args = request.POST.get("args")
try:
input_data = json.loads(raw_args) if raw_args else {}
except json.JSONDecodeError:
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message="Invalid JSON in 'args' field",
).to_response()
input_cls = getattr(fn_class, "Input", None)
if input_cls is not None and hasattr(input_cls, "model_fields"):
files = {
field: [
UploadedFile(f.name, f.content_type, f.read())
for f in request.FILES.getlist(field)
]
for field in request.FILES
}
err = bind_uploads(input_cls, input_data, files)
if err is not None:
return FunctionError(
code=ErrorCode.BAD_REQUEST,
message=err,
).to_response()
else: else:
# JSON body - standard RPC # JSON body - standard RPC

View File

@@ -1,79 +1,245 @@
""" """
JWT tokens — the Django adapter over the shared core (`mizan_core.auth.jwt`). JWT Token Creation and Validation
The token logic (mint/decode/refresh, `JWTUser`, `TokenPair`, `TokenPayload`) Uses PyJWT directly - no allauth dependency.
lives in the core; this module binds it to Django settings and keeps the Tokens are tied to Django sessions for immediate revocation on logout.
session-revocation check (`validate_session`), which is Django-session-specific.
""" """
from __future__ import annotations import time
from typing import NamedTuple
from mizan_core.auth import jwt as _core_jwt import jwt
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload from django.contrib.sessions.backends.base import SessionBase
from .settings import get_settings from .settings import get_settings
__all__ = [
"TokenPair", class TokenPair(NamedTuple):
"TokenPayload", """Access and refresh token pair."""
"JWTUser", access_token: str
"create_access_token", refresh_token: str
"create_refresh_token", expires_in: int
"create_token_pair",
"decode_token",
"validate_session",
"refresh_tokens",
]
def _config() -> JWTConfig: class TokenPayload(NamedTuple):
s = get_settings() """Decoded token payload."""
return JWTConfig( user_id: int | str
private_key=s.private_key, session_key: str
public_key=s.public_key, token_type: str
algorithm=s.algorithm, is_staff: bool
access_token_expires_in=s.access_token_expires_in, is_superuser: bool
refresh_token_expires_in=s.refresh_token_expires_in, exp: int
iat: int
class JWTUser:
"""
Minimal user object created from JWT claims.
Used as request.user for JWT-authenticated requests.
No database query required - all data comes from the token.
If you need the full User object with all fields, query explicitly:
user = User.objects.get(pk=request.user.id)
"""
def __init__(self, payload: TokenPayload):
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
self.pk = self.id
self.is_staff = payload.is_staff
self.is_superuser = payload.is_superuser
self.is_authenticated = True
self.is_anonymous = False
self.is_active = True # Assumed active if they have a valid token
def __str__(self):
return f"JWTUser(id={self.id})"
def __repr__(self):
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
def create_access_token(
user_id: int | str,
session_key: str,
*,
is_staff: bool = False,
is_superuser: bool = False,
) -> str:
"""
Create a short-lived access token.
The token contains:
- sub: user ID
- sid: session key (for revocation checking)
- staff: is_staff flag
- super: is_superuser flag
- type: "access"
- iat: issued at
- exp: expiration
"""
settings = get_settings()
now = int(time.time())
payload = {
"sub": str(user_id),
"sid": session_key,
"staff": is_staff,
"super": is_superuser,
"type": "access",
"iat": now,
"exp": now + settings.access_token_expires_in,
}
return jwt.encode(
payload,
settings.private_key,
algorithm=settings.algorithm,
) )
def create_access_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str: def create_refresh_token(
return _core_jwt.create_access_token(user_id, session_key, _config(), user_id: int | str,
is_staff=is_staff, is_superuser=is_superuser) session_key: str,
*,
is_staff: bool = False,
is_superuser: bool = False,
) -> str:
"""
Create a longer-lived refresh token.
The token contains:
- sub: user ID
- sid: session key (for revocation checking)
- staff: is_staff flag
- super: is_superuser flag
- type: "refresh"
- iat: issued at
- exp: expiration
"""
settings = get_settings()
now = int(time.time())
payload = {
"sub": str(user_id),
"sid": session_key,
"staff": is_staff,
"super": is_superuser,
"type": "refresh",
"iat": now,
"exp": now + settings.refresh_token_expires_in,
}
return jwt.encode(
payload,
settings.private_key,
algorithm=settings.algorithm,
)
def create_refresh_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str: def create_token_pair(
return _core_jwt.create_refresh_token(user_id, session_key, _config(), user_id: int | str,
is_staff=is_staff, is_superuser=is_superuser) session_key: str,
*,
is_staff: bool = False,
is_superuser: bool = False,
) -> TokenPair:
"""Create both access and refresh tokens."""
settings = get_settings()
return TokenPair(
access_token=create_access_token(
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
),
refresh_token=create_refresh_token(
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
),
expires_in=settings.access_token_expires_in,
)
def create_token_pair(user_id, session_key, *, is_staff=False, is_superuser=False) -> TokenPair: def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
return _core_jwt.create_token_pair(user_id, session_key, _config(), """
is_staff=is_staff, is_superuser=is_superuser) Decode and validate a JWT token.
Returns None if:
- Token is invalid or expired
- Token type doesn't match expected_type (if specified)
"""
settings = get_settings()
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None: try:
return _core_jwt.decode_token(token, _config(), expected_type=expected_type) payload = jwt.decode(
token,
settings.public_key,
algorithms=[settings.algorithm],
)
except jwt.PyJWTError:
return None
# Validate token type if specified
if expected_type and payload.get("type") != expected_type:
return None
return TokenPayload(
user_id=payload["sub"],
session_key=payload["sid"],
token_type=payload["type"],
is_staff=payload.get("staff", False),
is_superuser=payload.get("super", False),
exp=payload["exp"],
iat=payload["iat"],
)
def validate_session(session_key: str) -> bool: def validate_session(session_key: str) -> bool:
"""Immediate-logout revocation: is this Django session still alive? """
Check if a session is still valid (exists and not expired).
Honors `JWT_VALIDATE_SESSION` — when disabled, always True. This is the one This is the key to immediate logout revocation - if the session
Django-session-bound piece; the core's `refresh_tokens` takes it as an is destroyed, tokens tied to it become invalid.
injected `session_validator`.
""" """
from importlib import import_module from importlib import import_module
from django.conf import settings as django_settings from django.conf import settings as django_settings
if not get_settings().validate_session: jwt_settings = get_settings()
if not jwt_settings.validate_session:
return True return True
# Use the configured session engine
engine = import_module(django_settings.SESSION_ENGINE) engine = import_module(django_settings.SESSION_ENGINE)
session = engine.SessionStore(session_key=session_key) SessionStore = engine.SessionStore
# Try to load the session
session = SessionStore(session_key=session_key)
# Check if session exists and is not empty
# exists() is more reliable than checking load() result
return session.exists(session_key) return session.exists(session_key)
def refresh_tokens(refresh_token: str) -> TokenPair | None: def refresh_tokens(refresh_token: str) -> TokenPair | None:
return _core_jwt.refresh_tokens(refresh_token, _config(), session_validator=validate_session) """
Use a refresh token to obtain new tokens.
Returns None if:
- Refresh token is invalid or expired
- Associated session no longer exists
"""
payload = decode_token(refresh_token, expected_type="refresh")
if payload is None:
return None
# Validate the session still exists
if not validate_session(payload.session_key):
return None
# Issue new token pair with same claims
return create_token_pair(
payload.user_id,
payload.session_key,
is_staff=payload.is_staff,
is_superuser=payload.is_superuser,
)

View File

@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
def test_jwt_expired_with_session(self): def test_jwt_expired_with_session(self):
"""Expired JWT with valid session → Reject (do NOT fall back).""" """Expired JWT with valid session → Reject (do NOT fall back)."""
# Create token with past expiration by mocking time (minting lives in the core now) # Create token with past expiration by mocking time
with patch("mizan_core.auth.jwt.time.time", return_value=0): with patch("mizan.jwt.tokens.time.time", return_value=0):
tokens = create_token_pair( tokens = create_token_pair(
self.user.pk, self.user.pk,
self.session_key, self.session_key,

View File

@@ -1,73 +0,0 @@
"""Upload dispatch — multipart RPC binds files into Upload fields and enforces
the declarative `File(...)` constraints."""
import json
from typing import Annotated
from django.contrib.auth.models import AnonymousUser
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import HttpRequest
from django.test import RequestFactory, TestCase
from pydantic import BaseModel
from mizan import Upload, File
from mizan.client import client
from mizan.client.executor import function_call_view
from mizan_core.registry import clear_registry, register
class AvatarOut(BaseModel):
ok: bool
size: int
name: str | None = None
class UploadDispatchTests(TestCase):
def setUp(self):
clear_registry()
self.factory = RequestFactory()
def tearDown(self):
clear_registry()
def _register(self):
@client
def set_avatar(
request: HttpRequest,
user_id: int,
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
) -> AvatarOut:
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
register(set_avatar, "set_avatar")
def _post(self, args, files):
data = {"fn": "set_avatar", "args": json.dumps(args), **files}
request = self.factory.post("/api/mizan/call/", data) # multipart
request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True
return function_call_view(request)
def test_upload_binds_and_executes(self):
self._register()
png = SimpleUploadedFile("a.png", b"\x89PNG" + b"x" * 100, content_type="image/png")
resp = self._post({"user_id": 5}, {"avatar": png})
self.assertEqual(resp.status_code, 200)
data = json.loads(resp.content)
self.assertTrue(data["result"]["ok"])
self.assertEqual(data["result"]["name"], "a.png")
self.assertEqual(data["result"]["size"], 104)
def test_max_size_rejected(self):
self._register()
big = SimpleUploadedFile("b.png", b"x" * (2 * 1024 * 1024), content_type="image/png")
resp = self._post({"user_id": 5}, {"avatar": big})
self.assertEqual(resp.status_code, 400)
self.assertIn("max size", resp.content.decode())
def test_content_type_rejected(self):
self._register()
gif = SimpleUploadedFile("c.gif", b"GIF89a", content_type="image/gif")
resp = self._post({"user_id": 5}, {"avatar": gif})
self.assertEqual(resp.status_code, 400)
self.assertIn("content-type", resp.content.decode())

View File

@@ -8,7 +8,6 @@ dependencies = [
"mizan-core", "mizan-core",
"fastapi>=0.110", "fastapi>=0.110",
"pydantic>=2.0", "pydantic>=2.0",
"python-multipart>=0.0.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -35,18 +35,8 @@ from .executor import (
execute_function, execute_function,
) )
from .router import router, mizan_exception_handler, mizan_validation_handler from .router import router, mizan_exception_handler, mizan_validation_handler
from .auth import MizanAuthMiddleware, mizan_auth
from .config import MizanConfig, from_env
from mizan_core.upload import File, Upload, UploadedFile
__all__ = [ __all__ = [
"Upload",
"File",
"UploadedFile",
"mizan_auth",
"MizanAuthMiddleware",
"MizanConfig",
"from_env",
"router", "router",
"mizan_exception_handler", "mizan_exception_handler",
"mizan_validation_handler", "mizan_validation_handler",

View File

@@ -1,54 +0,0 @@
"""
Built-in identity for FastAPI — Django-equivalent automatic `request.state.user`.
Opt in via `Depends(mizan_auth())` on a route/router, or mount `MizanAuthMiddleware`
app-wide. Both decode a bearer-JWT (`Authorization: Bearer`) or MWT (`X-Mizan-Token`)
via the shared core and set `request.state.user`. A present-but-invalid token is
rejected (401) rather than silently downgraded — the `INVALID` sentinel contract.
If you'd rather resolve identity yourself, set `request.state.user` upstream and skip
these; dispatch reads it directly.
"""
from __future__ import annotations
from typing import Callable
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from mizan_core.auth import INVALID, authenticate
from mizan_core.errors import Unauthorized
from .config import get_config
def _resolve(request: Request) -> None:
ident = authenticate(request.headers, get_config(request).auth)
if ident is INVALID:
raise Unauthorized("Invalid or expired token")
if ident is not None:
request.state.user = ident
def mizan_auth() -> Callable:
"""FastAPI dependency that populates `request.state.user` from a token."""
async def _dep(request: Request) -> None:
_resolve(request)
return _dep
class MizanAuthMiddleware(BaseHTTPMiddleware):
"""App-wide variant of `mizan_auth` — resolves identity on every request."""
async def dispatch(self, request, call_next):
try:
_resolve(request)
except Unauthorized:
from .router import _no_store
from mizan_core.errors import ErrorCode
return _no_store(
{"error": {"code": ErrorCode.UNAUTHORIZED.value, "message": "Invalid or expired token"}},
status_code=401,
)
return await call_next(request)

View File

@@ -1,80 +0,0 @@
"""
FastAPI configuration — the "no settings.py" seam.
Builds the shared core's `AuthConfig` (JWT + MWT) and a `CacheOrchestrator`
from environment variables, overridable per-app via `app.state.mizan_config`.
Env:
MIZAN_CACHE_SECRET HMAC cache signing key (enables origin cache)
MIZAN_CACHE_REDIS_URL Redis URL (else in-memory cache)
MIZAN_MWT_SECRET MWT signing key
MIZAN_MWT_AUDIENCE MWT audience (default "mizan")
JWT_PRIVATE_KEY JWT signing key (enables bearer-JWT auth)
JWT_PUBLIC_KEY JWT verify key (default: private key, HS256)
JWT_ALGORITHM default "HS256"
JWT_ACCESS_TOKEN_EXPIRES_IN / JWT_REFRESH_TOKEN_EXPIRES_IN
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from mizan_core.auth import AuthConfig, JWTConfig
from mizan_core.cache.backend import CacheBackend, MemoryCache
from mizan_core.dispatch import CacheOrchestrator
@dataclass(frozen=True)
class MizanConfig:
auth: AuthConfig
cache: CacheOrchestrator
def _cache_backend(secret: str | None, redis_url: str | None) -> CacheBackend | None:
if not secret:
return None
if redis_url:
from mizan_core.cache.backend import RedisCache
return RedisCache(redis_url)
return MemoryCache()
def _jwt_config() -> JWTConfig | None:
key = os.getenv("JWT_PRIVATE_KEY")
if not key:
return None
return JWTConfig(
private_key=key,
public_key=os.getenv("JWT_PUBLIC_KEY", key),
algorithm=os.getenv("JWT_ALGORITHM", "HS256"),
access_token_expires_in=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES_IN", "300")),
refresh_token_expires_in=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES_IN", "604800")),
)
def from_env() -> MizanConfig:
secret = os.getenv("MIZAN_CACHE_SECRET")
backend = _cache_backend(secret, os.getenv("MIZAN_CACHE_REDIS_URL"))
auth = AuthConfig(
jwt=_jwt_config(),
mwt_secret=os.getenv("MIZAN_MWT_SECRET"),
mwt_audience=os.getenv("MIZAN_MWT_AUDIENCE", "mizan"),
)
return MizanConfig(auth=auth, cache=CacheOrchestrator(backend, secret))
def get_config(request) -> MizanConfig:
"""Per-app config: `app.state.mizan_config` if set, else built from env (cached)."""
app = getattr(request, "app", None)
state = getattr(app, "state", None)
override = getattr(state, "mizan_config", None) if state is not None else None
if override is not None:
return override
global _DEFAULT
if _DEFAULT is None:
_DEFAULT = from_env()
return _DEFAULT
_DEFAULT: MizanConfig | None = None

View File

@@ -1,69 +1,263 @@
""" """
Dispatch — a thin shim over the shared core (`mizan_core.dispatch`). RPC dispatch — looks up registered functions, validates input against the
function's Pydantic Input model, executes, and returns the serialized result.
The protocol machinery (auth, validation, execution, invalidation, merge, cache) Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
lives in `mizan_core`; this module re-exports the canonical error taxonomy and responses by registering `mizan_exception_handler` on the FastAPI app, or
keeps backward-compatible helpers. The router drives `dispatch_call` / let them propagate to your own handler.
`dispatch_context` directly to get invalidation + origin cache.
""" """
from __future__ import annotations from __future__ import annotations
from enum import Enum
from typing import Any from typing import Any
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call from fastapi.encoders import jsonable_encoder
from mizan_core.errors import ( from pydantic import BaseModel, ValidationError
BadRequest,
ErrorCode,
Forbidden,
InternalError,
MizanError,
NotFound,
NotImplementedYet,
Unauthorized,
ValidationFailed,
)
from mizan_core.invalidation import resolve_invalidation, resolve_merges
__all__ = [ from mizan_core.registry import get_context_groups, get_function
"ErrorCode", from mizan_core.type_utils import types_match_for_merge
"MizanError",
"NotFound",
"BadRequest",
"ValidationFailed",
"Unauthorized",
"Forbidden",
"NotImplementedYet",
"InternalError",
"compute_invalidation",
"compute_merges",
"execute_function",
]
_NO_CACHE = CacheOrchestrator(None, None) # ─── Error taxonomy ─────────────────────────────────────────────────────────
class ErrorCode(str, Enum):
NOT_FOUND = "NOT_FOUND"
BAD_REQUEST = "BAD_REQUEST"
VALIDATION_ERROR = "VALIDATION_ERROR"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
INTERNAL_ERROR = "INTERNAL_ERROR"
_STATUS = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.NOT_IMPLEMENTED: 501,
ErrorCode.INTERNAL_ERROR: 500,
}
class MizanError(Exception):
"""Base for protocol-level dispatch errors."""
code: ErrorCode = ErrorCode.INTERNAL_ERROR
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = details
@property
def status_code(self) -> int:
return _STATUS[self.code]
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
# ─── Auth ───────────────────────────────────────────────────────────────────
def _user(request: Any) -> Any:
return getattr(getattr(request, "state", None), "user", None)
def _is_authenticated(user: Any) -> bool:
return bool(user) and getattr(user, "is_authenticated", True)
def _enforce_auth(request: Any, requirement: Any) -> None:
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
if requirement is None:
return
user = _user(request)
match requirement:
case True | "required":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
case "staff":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_staff", False):
raise Forbidden("Staff access required")
case "superuser":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_superuser", False):
raise Forbidden("Superuser access required")
case f if callable(f):
if not f(request):
raise Forbidden("Permission denied")
case other:
raise InternalError(f"Unknown auth requirement: {other!r}")
# ─── Input validation ───────────────────────────────────────────────────────
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
"""Validate input_data against the function's Input model. Returns the instance or None."""
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
return None
fields = input_cls.model_fields
required = [name for name, f in fields.items() if f.is_required()]
if not input_data:
if required:
raise ValidationFailed(
"Input validation failed",
details={"fields": {name: ["Field required"] for name in required}},
)
return input_cls()
if not isinstance(input_data, dict):
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
try:
return input_cls(**input_data)
except ValidationError as e:
raise ValidationFailed(
"Input validation failed",
details={"errors": e.errors()},
) from e
# ─── Dispatch ───────────────────────────────────────────────────────────────
def _resolve_function(fn_name: str) -> Any:
view_class = get_function(fn_name)
if view_class is None:
raise NotFound("Function not found")
if getattr(view_class, "_meta", {}).get("private"):
raise Forbidden("Function is not client-callable")
return view_class
def _serialize(result: Any) -> Any:
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
# (and nested shapes) come out wire-ready without a per-shape branch here.
return jsonable_encoder(result)
async def execute_function(
request: Any,
fn_name: str,
input_data: dict[str, Any] | None = None,
) -> Any:
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
Awaits `view.acall` — async handlers run on the loop, sync handlers run
in the default threadpool, both via the same entrypoint.
"""
view_class = _resolve_function(fn_name)
_enforce_auth(request, view_class._meta.get("auth"))
view = view_class(request)
validated = _validate_input(view.Input, input_data)
try:
result = await view.acall(validated)
except NotImplementedError as e:
raise NotImplementedYet(str(e) or "Not implemented") from e
except MizanError:
raise
except Exception as e:
raise InternalError(str(e)) from e
return _serialize(result)
# ─── Invalidation ───────────────────────────────────────────────────────────
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]: def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
"""`@client(affects=...)` → invalidation list (empty when none). Shared core.""" """Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
return resolve_invalidation(view_class, input_data) or [] affects = getattr(view_class, "_meta", {}).get("affects") or []
return [_invalidation_target(target, input_data or {}) for target in affects]
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]: def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
"""`@client(merge=...)` → merge list (empty when none). Shared core.""" """Build the `merge` list from @client(merge=...) metadata.
return resolve_merges(view_class, input_data, result) or []
Each entry is `{context, slot, value, params?}` where `slot` names the
async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any: function inside the context bundle the value lands in. The slot is
"""Dispatch a function and return its serialized result (auth enforced via core). resolved server-side via `types_match_for_merge` so the kernel does
no shape inference — the server has the schema, type-checked routing
Backward-compat entry point; the router uses `dispatch_call` directly to also lives here. Entries whose slot can't be uniquely resolved are dropped
capture invalidation/merge and run the origin cache. with a warning; the consumer falls back to refetch via `affects`.
""" """
identity = getattr(getattr(request, "state", None), "user", None) targets = getattr(view_class, "_meta", {}).get("merge") or []
res = await dispatch_call( if not targets:
DispatchRequest(identity=identity, args=input_data, native_request=request), return []
fn_name, mutation_output = getattr(view_class, "Output", None)
_NO_CACHE, out: list[dict[str, Any]] = []
) for ctx_name in targets:
return res.data slot = _resolve_merge_slot(ctx_name, mutation_output)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
scoped = _scoped_params(ctx_name, input_data or {})
if scoped:
entry["params"] = scoped
out.append(entry)
return out
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
"""Find the unique function-name slot whose return type matches the mutation's output.
Returns None on no match or ambiguous match (multiple candidates).
"""
if mutation_output is None:
return None
matches: list[str] = []
for fn_name in get_context_groups().get(context_name, []):
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
"""Match input args against the context's declared Input field names."""
fn_names = get_context_groups().get(context_name, [])
declared: set[str] = set()
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
declared.update(input_cls.model_fields.keys())
return {k: v for k, v in input_data.items() if k in declared}
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
match target.get("type"):
case "context":
name = target["name"]
scoped = _scoped_params(name, input_data)
return {"context": name, "params": scoped} if scoped else name
case "function":
return {"function": target["name"]}
case _:
return target

View File

@@ -14,22 +14,23 @@ FastAPI router exposing Mizan's HTTP endpoints:
from __future__ import annotations from __future__ import annotations
import json
from typing import Any from typing import Any
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field
from starlette.datastructures import UploadFile
from mizan_core.auth import INVALID, authenticate from mizan_core.registry import get_context_groups, get_function
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
from mizan_core.registry import get_function
from mizan_core.upload import UploadedFile, bind_uploads
from .config import MizanConfig, get_config from .executor import (
ErrorCode,
MizanError,
NotFound,
compute_invalidation,
compute_merges,
execute_function,
)
router = APIRouter() router = APIRouter()
@@ -58,95 +59,29 @@ class CallBody(BaseModel):
args: dict[str, Any] = Field(default_factory=dict) args: dict[str, Any] = Field(default_factory=dict)
async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
"""Read a call request, JSON or multipart. Returns `(fn, args)`.
Multipart carries the non-file fields in a JSON `args` part and each file as
its own part; the file parts bind into the Input's Upload fields with the
declarative `File(...)` constraints enforced.
"""
content_type = request.headers.get("content-type", "")
if content_type.startswith("multipart/form-data"):
form = await request.form()
fn = form.get("fn")
if not isinstance(fn, str) or not fn:
raise BadRequest("Missing 'fn' field")
raw_args = form.get("args")
try:
args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
except (TypeError, ValueError):
raise BadRequest("Invalid JSON in 'args' field")
fn_class = get_function(fn)
input_cls = getattr(fn_class, "Input", None) if fn_class else None
if input_cls is not None and hasattr(input_cls, "model_fields"):
files: dict[str, list[UploadedFile]] = {}
for key in set(form.keys()):
wrapped = [
UploadedFile(p.filename, p.content_type, await p.read())
for p in form.getlist(key)
if isinstance(p, UploadFile)
]
if wrapped:
files[key] = wrapped
err = bind_uploads(input_cls, args, files)
if err is not None:
raise BadRequest(err)
return fn, args
try:
body = CallBody(**(await request.json()))
except (ValueError, ValidationError):
raise BadRequest("Invalid request body")
return body.fn, body.args
def _identity(request: Request, cfg: MizanConfig):
"""Identity for dispatch: a host-set `request.state.user`, else a token decode.
A present-but-invalid token rejects (401); no token → None (anonymous).
"""
existing = getattr(getattr(request, "state", None), "user", None)
if existing is not None:
return existing
ident = authenticate(request.headers, cfg.auth)
if ident is INVALID:
raise Unauthorized("Invalid or expired token")
return ident
@router.post("/call/") @router.post("/call/")
async def function_call(request: Request) -> JSONResponse: async def function_call(body: CallBody, request: Request) -> JSONResponse:
"""RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with """RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
the `X-Mizan-Invalidate` header alongside the body.""" fn_class = get_function(body.fn)
cfg = get_config(request) result = await execute_function(request, body.fn, body.args)
fn, args = await _parse_call(request) invalidate = compute_invalidation(fn_class, body.args)
res = await dispatch_call( merges = compute_merges(fn_class, body.args, result)
DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request), payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
fn, cfg.cache, if merges:
) payload["merge"] = merges
payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []} return _no_store(payload)
if res.merge:
payload["merge"] = res.merge
headers = {"Cache-Control": "no-store"}
if res.invalidate_header:
headers["X-Mizan-Invalidate"] = res.invalidate_header
return JSONResponse(payload, headers=headers)
@router.get("/ctx/{context_name}/") @router.get("/ctx/{context_name}/")
async def context_fetch(context_name: str, request: Request) -> Response: async def context_fetch(context_name: str, request: Request) -> JSONResponse:
"""Bundled context fetch — origin-cached. `{function_name: result, ...}`.""" """Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
cfg = get_config(request) fn_names = get_context_groups().get(context_name)
res = await dispatch_context( if not fn_names:
DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params), raise NotFound(f"Context '{context_name}' not found")
native_request=request),
context_name, cfg.cache, params = dict(request.query_params)
) bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
headers = {"Cache-Control": "no-store"} return _no_store(bundled)
if res.cache_status:
headers["X-Mizan-Cache"] = res.cache_status
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
# ─── Exception handler ────────────────────────────────────────────────────── # ─── Exception handler ──────────────────────────────────────────────────────

View File

@@ -1,98 +0,0 @@
"""FastAPI parity with Django: X-Mizan-Invalidate header, origin cache, token auth."""
from __future__ import annotations
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from mizan_core.auth import AuthConfig, JWTConfig, create_access_token
from mizan_core.cache.backend import MemoryCache
from mizan_core.client.function import client
from mizan_core.dispatch import CacheOrchestrator
from mizan_core.registry import clear_registry, register
from mizan_fastapi import (
MizanAuthMiddleware,
MizanConfig,
MizanError,
mizan_auth,
mizan_exception_handler,
router as mizan_router,
)
class Out(BaseModel):
ok: bool
SECRET = "x" * 32
JWT = JWTConfig(private_key=SECRET, public_key=SECRET)
def _app(*, with_cache=False, with_auth_dep=False) -> FastAPI:
clear_registry()
UserCtx = "user"
@client(context=UserCtx)
def user_profile(request, user_id: int) -> Out:
return Out(ok=True)
@client(affects=UserCtx)
def update_profile(request, user_id: int) -> Out:
return Out(ok=True)
@client(auth=True)
def whoami(request) -> Out:
return Out(ok=True)
register(user_profile, "user_profile")
register(update_profile, "update_profile")
register(whoami, "whoami")
app = FastAPI()
cache = CacheOrchestrator(MemoryCache(), SECRET) if with_cache else CacheOrchestrator(None, None)
app.state.mizan_config = MizanConfig(auth=AuthConfig(jwt=JWT), cache=cache)
deps = [Depends(mizan_auth())] if with_auth_dep else []
app.include_router(mizan_router, prefix="/api/mizan", dependencies=deps)
app.add_exception_handler(MizanError, mizan_exception_handler)
return app
def test_mutation_emits_invalidate_header():
c = TestClient(_app())
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 5}})
assert r.status_code == 200
assert r.json()["invalidate"] == [{"context": "user", "params": {"user_id": 5}}]
assert r.headers["X-Mizan-Invalidate"] == "user;user_id=5"
def test_origin_cache_hit_miss():
c = TestClient(_app(with_cache=True))
r1 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
assert r1.status_code == 200 and r1.headers["X-Mizan-Cache"] == "MISS"
r2 = c.get("/api/mizan/ctx/user/", params={"user_id": 5})
assert r2.headers["X-Mizan-Cache"] == "HIT"
assert r1.content == r2.content
def test_auth_required_rejects_anonymous():
c = TestClient(_app())
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
assert r.status_code == 401
def test_auth_required_passes_with_bearer_jwt():
c = TestClient(_app(with_auth_dep=True))
tok = create_access_token("7", "sess", JWT, is_staff=True)
r = c.post("/api/mizan/call/", json={"fn": "whoami", "args": {}},
headers={"Authorization": f"Bearer {tok}"})
assert r.status_code == 200 and r.json()["result"] == {"ok": True}
def test_invalid_bearer_token_rejected():
c = TestClient(_app())
r = c.post("/api/mizan/call/", json={"fn": "update_profile", "args": {"user_id": 1}},
headers={"Authorization": "Bearer not-a-real-token"})
assert r.status_code == 401

View File

@@ -1,71 +0,0 @@
"""Upload dispatch over FastAPI multipart — files bind into Upload fields and
the declarative `File(...)` constraints are enforced."""
from __future__ import annotations
import json
from typing import Annotated
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import clear_registry, register
from mizan_fastapi import File, MizanError, Upload, mizan_exception_handler, router as mizan_router
class AvatarOut(BaseModel):
ok: bool
size: int
name: str | None = None
@pytest.fixture
def app():
clear_registry()
@client
def set_avatar(
request,
user_id: int,
avatar: Annotated[Upload, File(max_size="1MB", content_types=["image/png"])],
) -> AvatarOut:
return AvatarOut(ok=True, size=avatar.size, name=avatar.filename)
register(set_avatar, "set_avatar")
fastapi_app = FastAPI()
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
return fastapi_app
def _post(test_client: TestClient, args: dict, file_tuple: tuple):
return test_client.post(
"/api/mizan/call/",
data={"fn": "set_avatar", "args": json.dumps(args)},
files={"avatar": file_tuple},
)
def test_upload_binds_and_executes(app):
resp = _post(TestClient(app), {"user_id": 5}, ("a.png", b"\x89PNG" + b"x" * 100, "image/png"))
assert resp.status_code == 200, resp.text
result = resp.json()["result"]
assert result["ok"] is True
assert result["name"] == "a.png"
assert result["size"] == 104
def test_max_size_rejected(app):
resp = _post(TestClient(app), {"user_id": 5}, ("b.png", b"x" * (2 * 1024 * 1024), "image/png"))
assert resp.status_code == 400
assert "max size" in resp.text
def test_content_type_rejected(app):
resp = _post(TestClient(app), {"user_id": 5}, ("c.gif", b"GIF89a", "image/gif"))
assert resp.status_code == 400
assert "content-type" in resp.text

View File

@@ -13,7 +13,7 @@
* } * }
*/ */
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types' import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
import { register } from './registry' import { register } from './registry'
function resolveContext(ctx: ReactContext | string | undefined): string | undefined { function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
@@ -21,19 +21,6 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
return ctx return ctx
} }
/**
* Normalize the public auth option into the stored requirement.
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
* 'staff'/'superuser' pass through, anything else throws at decoration time.
*/
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
if (auth === undefined) return undefined
if (auth === true) return 'required'
if (typeof auth === 'function') return auth
if (auth === 'staff' || auth === 'superuser') return auth
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
}
function normalizeAffects( function normalizeAffects(
affects: ClientOptions['affects'], affects: ClientOptions['affects'],
): RegistryEntry['affects'] | undefined { ): RegistryEntry['affects'] | undefined {
@@ -110,7 +97,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
viewPath: isView, viewPath: isView,
route: options.route, route: options.route,
methods: options.methods, methods: options.methods,
auth: normalizeAuth(options.auth), auth: options.auth,
rev: options.rev, rev: options.rev,
cache: options.cache, cache: options.cache,
} }
@@ -142,7 +129,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
viewPath: false, viewPath: false,
route: options.route, route: options.route,
methods: options.methods, methods: options.methods,
auth: normalizeAuth(options.auth), auth: options.auth,
rev: options.rev, rev: options.rev,
cache: options.cache, cache: options.cache,
} }

View File

@@ -8,8 +8,6 @@
import { getFunction, getContextGroups } from './registry' import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation' import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache' import { getCache, cacheGet, cachePut, cachePurge } from './cache'
import { ANONYMOUS, type Identity } from './identity'
import type { AuthRequirement } from './types'
let _cacheSecret: string | null = null let _cacheSecret: string | null = null
@@ -24,54 +22,6 @@ export interface MizanResponse {
headers: Record<string, string> headers: Record<string, string>
} }
interface AuthDenial {
status: 401 | 403
code: 'UNAUTHORIZED' | 'FORBIDDEN'
message: string
}
/**
* Check whether `identity` satisfies the stored `auth` requirement.
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
* on failure, or null when access is allowed.
*/
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
if (auth === undefined) return null
// Callable runs first — before the authentication gate.
if (typeof auth === 'function') {
try {
return auth(identity)
? null
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
} catch (e: any) {
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
}
}
if (!identity.isAuthenticated) {
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
}
if (auth === 'staff' && !identity.isStaff) {
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
}
if (auth === 'superuser' && !identity.isSuperuser) {
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
}
return null
}
function authDenialResponse(denial: AuthDenial): MizanResponse {
return {
status: denial.status,
body: { error: true, code: denial.code, message: denial.message },
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
}
}
/** /**
* Handle GET /api/mizan/ctx/:contextName/ * Handle GET /api/mizan/ctx/:contextName/
* *
@@ -80,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse {
export async function handleContextFetch( export async function handleContextFetch(
contextName: string, contextName: string,
params: Record<string, string>, params: Record<string, string>,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> { ): Promise<MizanResponse> {
const groups = getContextGroups() const groups = getContextGroups()
const fnNames = groups[contextName] const fnNames = groups[contextName]
@@ -93,15 +42,6 @@ export async function handleContextFetch(
} }
} }
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
// leak to an unauthorized caller. Any denial short-circuits, uncached.
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
const denial = checkAuth(entry.auth, identity)
if (denial) return authDenialResponse(denial)
}
// Resolve effective rev (max across functions) and cache policy (min TTL) // Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0 let effectiveRev = 0
for (const fnName of fnNames) { for (const fnName of fnNames) {
@@ -193,7 +133,6 @@ export async function handleContextFetch(
export async function handleMutationCall( export async function handleMutationCall(
fnName: string, fnName: string,
args: Record<string, any>, args: Record<string, any>,
identity: Identity = ANONYMOUS,
): Promise<MizanResponse> { ): Promise<MizanResponse> {
const entry = getFunction(fnName) const entry = getFunction(fnName)
@@ -214,10 +153,6 @@ export async function handleMutationCall(
} }
} }
// Auth enforcement — after private rejection, before execution.
const denial = checkAuth(entry.auth, identity)
if (denial) return authDenialResponse(denial)
try { try {
const argValues = entry.params.map(p => args[p.name]) const argValues = entry.params.map(p => args[p.name])
const result = await entry.fn(...argValues) const result = await entry.fn(...argValues)

View File

@@ -1,22 +0,0 @@
/**
* Identity abstraction — the request-bound caller identity.
*
* Framework-agnostic. Adapters construct an Identity (from MWT, JWT,
* session, etc.) and pass it into dispatch. ANONYMOUS is the default.
*/
export interface Identity {
isAuthenticated: boolean
isStaff: boolean
isSuperuser: boolean
id: number | string | null
}
export const ANONYMOUS: Identity = {
isAuthenticated: false,
isStaff: false,
isSuperuser: false,
id: null,
}
export type AuthPredicate = (identity: Identity) => boolean

View File

@@ -1,11 +1,5 @@
export { ReactContext } from './types' export { ReactContext } from './types'
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types' export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
export { ANONYMOUS } from './identity'
export type { Identity, AuthPredicate } from './identity'
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
export type { MwtPayload } from './token'
export { client } from './decorator' export { client } from './decorator'

View File

@@ -1,110 +0,0 @@
/**
* MWT / JWT decode — HS256 verification, cross-language parity with
* cores/mizan-python/src/mizan_core/mwt.py.
*
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
* aud, malformed). Never throws.
*/
import { createHmac, timingSafeEqual } from 'crypto'
import type { Identity } from './identity'
export interface MwtPayload {
sub: string
staff: boolean
super: boolean
pkey: string
kid: string
aud: string
iat: number
exp: number
}
function base64urlDecode(input: string): Buffer | null {
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null
return Buffer.from(input, 'base64url')
}
function constantTimeEqual(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
/**
* Decode and validate an MWT (HS256 JWT with Mizan claims).
* Returns MwtPayload on success, null on any failure.
*/
export function decodeMwt(
token: string,
secret: string,
audience: string = 'mizan',
): MwtPayload | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const [headerB64, payloadB64, signatureB64] = parts
const headerBytes = base64urlDecode(headerB64)
const payloadBytes = base64urlDecode(payloadB64)
const signatureBytes = base64urlDecode(signatureB64)
if (!headerBytes || !payloadBytes || !signatureBytes) return null
const header = JSON.parse(headerBytes.toString('utf-8'))
if (header.alg !== 'HS256') return null
// Recompute HMAC over `${headerB64}.${payloadB64}`
const expected = createHmac('sha256', secret)
.update(`${headerB64}.${payloadB64}`)
.digest()
if (!constantTimeEqual(expected, signatureBytes)) return null
const data = JSON.parse(payloadBytes.toString('utf-8'))
const now = Math.floor(Date.now() / 1000)
if (typeof data.exp !== 'number' || data.exp <= now) return null
if (data.nbf !== undefined && typeof data.nbf === 'number' && data.nbf > now) return null
if (data.aud !== audience) return null
const kid = typeof header.kid === 'string' ? header.kid : 'v1'
return {
sub: String(data.sub),
staff: Boolean(data.staff),
super: Boolean(data.super),
pkey: typeof data.pkey === 'string' ? data.pkey : '',
kid,
aud: audience,
iat: data.iat,
exp: data.exp,
}
} catch {
return null
}
}
/**
* Decode a Bearer JWT from an Authorization header value.
* Strips the "Bearer " prefix, then validates as an MWT.
*/
export function decodeJwtBearer(
authHeader: string,
secret: string,
audience: string = 'mizan',
): MwtPayload | null {
if (!authHeader) return null
const prefix = 'Bearer '
const token = authHeader.startsWith(prefix)
? authHeader.slice(prefix.length)
: authHeader
return decodeMwt(token, secret, audience)
}
/** Build an Identity from a decoded MWT payload. */
export function identityFromMwt(payload: MwtPayload): Identity {
return {
isAuthenticated: true,
isStaff: payload.staff,
isSuperuser: payload.super,
id: Number(payload.sub),
}
}

View File

@@ -2,8 +2,6 @@
* Mizan TypeScript Adapter — Shared Types * Mizan TypeScript Adapter — Shared Types
*/ */
import type { AuthPredicate } from './identity'
export class ReactContext { export class ReactContext {
constructor(public readonly name: string) { constructor(public readonly name: string) {
if (!name) throw new Error('ReactContext name must be non-empty') if (!name) throw new Error('ReactContext name must be non-empty')
@@ -12,19 +10,13 @@ export class ReactContext {
export type AffectsTarget = ReactContext | string export type AffectsTarget = ReactContext | string
/** Public auth option on the decorator. `true` normalizes to `'required'` when stored. */
export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
/** Normalized auth requirement as stored on the registry entry. */
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
export interface ClientOptions { export interface ClientOptions {
context?: ReactContext | string context?: ReactContext | string
affects?: AffectsTarget | AffectsTarget[] affects?: AffectsTarget | AffectsTarget[]
private?: boolean private?: boolean
route?: string route?: string
methods?: string[] methods?: string[]
auth?: AuthOption auth?: boolean
rev?: number rev?: number
cache?: number | false cache?: number | false
} }
@@ -45,7 +37,7 @@ export interface RegistryEntry {
viewPath: boolean viewPath: boolean
route?: string route?: string
methods?: string[] methods?: string[]
auth?: AuthRequirement auth?: boolean
rev?: number rev?: number
cache?: number | false cache?: number | false
} }

View File

@@ -1,163 +0,0 @@
/**
* Auth-parity tests — mirrors Django's auth enforcement in
* mizan-django/src/mizan/client/executor.py (_check_auth_requirement).
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import {
ReactContext, client, clearRegistry,
handleContextFetch, handleMutationCall,
setCache, resetCache, setCacheSecret, MemoryCache,
type Identity,
} from '../src'
function anon(): Identity {
return { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
}
function user(): Identity {
return { isAuthenticated: true, isStaff: false, isSuperuser: false, id: 1 }
}
function staff(): Identity {
return { isAuthenticated: true, isStaff: true, isSuperuser: false, id: 2 }
}
function superuser(): Identity {
return { isAuthenticated: true, isStaff: true, isSuperuser: true, id: 3 }
}
describe('Auth — mutation dispatch', () => {
beforeEach(() => clearRegistry())
test('auth:true + anon → 401', async () => {
client({ auth: true }, async function secret() { return { ok: true } })
const r = await handleMutationCall('secret', {}, anon())
expect(r.status).toBe(401)
expect(r.body.code).toBe('UNAUTHORIZED')
expect(r.body.message).toBe('Authentication required')
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('auth:true + user → 200', async () => {
client({ auth: true }, async function secret() { return { ok: true } })
const r = await handleMutationCall('secret', {}, user())
expect(r.status).toBe(200)
expect(r.body.result).toEqual({ ok: true })
})
test("auth:'staff' + user → 403", async () => {
client({ auth: 'staff' }, async function adminAction() { return { ok: true } })
const r = await handleMutationCall('adminAction', {}, user())
expect(r.status).toBe(403)
expect(r.body.code).toBe('FORBIDDEN')
expect(r.body.message).toBe('Staff access required')
})
test("auth:'staff' + staff → 200", async () => {
client({ auth: 'staff' }, async function adminAction() { return { ok: true } })
const r = await handleMutationCall('adminAction', {}, staff())
expect(r.status).toBe(200)
})
test("auth:'superuser' + staff → 403", async () => {
client({ auth: 'superuser' }, async function nuke() { return { ok: true } })
const r = await handleMutationCall('nuke', {}, staff())
expect(r.status).toBe(403)
expect(r.body.message).toBe('Superuser access required')
})
test("auth:'superuser' + superuser → 200", async () => {
client({ auth: 'superuser' }, async function nuke() { return { ok: true } })
const r = await handleMutationCall('nuke', {}, superuser())
expect(r.status).toBe(200)
})
test('callable → true → 200', async () => {
client({ auth: (id) => id.isAuthenticated }, async function gated() { return { ok: true } })
const r = await handleMutationCall('gated', {}, user())
expect(r.status).toBe(200)
})
test("callable → false → 403 'Access denied'", async () => {
client({ auth: () => false }, async function gated() { return { ok: true } })
const r = await handleMutationCall('gated', {}, user())
expect(r.status).toBe(403)
expect(r.body.message).toBe('Access denied')
})
test("callable throws Error('msg') → 403 'msg'", async () => {
client({ auth: () => { throw new Error('msg') } }, async function gated() { return { ok: true } })
const r = await handleMutationCall('gated', {}, user())
expect(r.status).toBe(403)
expect(r.body.message).toBe('msg')
})
test('callable runs before authentication gate (anon allowed if predicate true)', async () => {
client({ auth: () => true }, async function gated() { return { ok: true } })
const r = await handleMutationCall('gated', {}, anon())
expect(r.status).toBe(200)
})
test('invalid auth string at decoration → throws', () => {
expect(() => {
client({ auth: 'admin' as any }, async function bad() { return {} })
}).toThrow('Invalid auth value')
})
test('no auth + anon → 200 (default ANONYMOUS path stays open)', async () => {
client({}, async function open() { return { ok: true } })
const r = await handleMutationCall('open', {})
expect(r.status).toBe(200)
})
})
describe('Auth — context fetch', () => {
beforeEach(() => clearRegistry())
test('auth-gated context member + anon → 401', async () => {
const Ctx = new ReactContext('secure')
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
return { id: itemId }
})
const r = await handleContextFetch('secure', { itemId: '1' }, anon())
expect(r.status).toBe(401)
expect(r.body.message).toBe('Authentication required')
})
test('auth-gated context + user → 200', async () => {
const Ctx = new ReactContext('secure')
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
return { id: itemId }
})
const r = await handleContextFetch('secure', { itemId: '1' }, user())
expect(r.status).toBe(200)
expect(r.body.secureData).toEqual({ id: '1' })
})
test('context fetch denial pre-empts a would-be cache HIT', async () => {
const SECRET = 'auth-test-secret-32bytes-padding!'
const Ctx = new ReactContext('secure')
client({ context: Ctx, auth: true }, async function secureData(itemId: number) {
return { id: itemId }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime the cache as an authorized caller.
const primed = await handleContextFetch('secure', { itemId: '1' }, user())
expect(primed.status).toBe(200)
expect(primed.headers['X-Mizan-Cache']).toBe('MISS')
// Confirm it's now a cache HIT for an authorized caller.
const hit = await handleContextFetch('secure', { itemId: '1' }, user())
expect(hit.headers['X-Mizan-Cache']).toBe('HIT')
// Anon must get 401 even though the cache holds the entry.
const denied = await handleContextFetch('secure', { itemId: '1' }, anon())
expect(denied.status).toBe(401)
expect(denied.headers['X-Mizan-Cache']).toBeUndefined()
resetCache()
setCacheSecret(null)
})
})

View File

@@ -1,126 +0,0 @@
/**
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
*/
import { describe, test, expect } from 'bun:test'
import { createHmac } from 'crypto'
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
function b64url(buf: Buffer | string): string {
return Buffer.from(buf).toString('base64url')
}
/** Mint an HS256 MWT with node crypto, mirroring Python create_mwt. */
function mint(payload: Record<string, any>, secret: string, kid = 'v1'): string {
const header = b64url(JSON.stringify({ alg: 'HS256', kid, typ: 'JWT' }))
const body = b64url(JSON.stringify(payload))
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
return `${header}.${body}.${sig}`
}
const SECRET = 'round-trip-secret'
const now = Math.floor(Date.now() / 1000)
function basePayload(overrides: Record<string, any> = {}) {
return {
sub: '7',
staff: true,
super: false,
pkey: 'abc123',
aud: 'mizan',
iat: now,
nbf: now,
exp: now + 300,
...overrides,
}
}
describe('MWT round-trip', () => {
test('valid token decodes', () => {
const token = mint(basePayload(), SECRET)
const p = decodeMwt(token, SECRET)
expect(p).not.toBeNull()
expect(p!.sub).toBe('7')
expect(p!.staff).toBe(true)
expect(p!.super).toBe(false)
expect(p!.pkey).toBe('abc123')
expect(p!.kid).toBe('v1')
expect(p!.aud).toBe('mizan')
})
test('identityFromMwt maps claims', () => {
const token = mint(basePayload({ sub: '99', staff: false, super: true }), SECRET)
const p = decodeMwt(token, SECRET)!
expect(identityFromMwt(p)).toEqual({
isAuthenticated: true,
isStaff: false,
isSuperuser: true,
id: 99,
})
})
test('decodeJwtBearer strips Bearer prefix', () => {
const token = mint(basePayload(), SECRET)
const p = decodeJwtBearer(`Bearer ${token}`, SECRET)
expect(p).not.toBeNull()
expect(p!.sub).toBe('7')
})
test('null on tampered signature', () => {
const token = mint(basePayload(), SECRET)
const tampered = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA')
expect(decodeMwt(tampered, SECRET)).toBeNull()
})
test('null on wrong secret', () => {
const token = mint(basePayload(), SECRET)
expect(decodeMwt(token, 'other-secret')).toBeNull()
})
test('null on expired exp', () => {
const token = mint(basePayload({ exp: now - 10 }), SECRET)
expect(decodeMwt(token, SECRET)).toBeNull()
})
test('null on future nbf', () => {
const token = mint(basePayload({ nbf: now + 1000 }), SECRET)
expect(decodeMwt(token, SECRET)).toBeNull()
})
test('null on wrong aud', () => {
const token = mint(basePayload({ aud: 'other' }), SECRET)
expect(decodeMwt(token, SECRET)).toBeNull()
})
test('null on malformed token', () => {
expect(decodeMwt('not.a.jwt', SECRET)).toBeNull()
expect(decodeMwt('onlyonepart', SECRET)).toBeNull()
expect(decodeMwt('', SECRET)).toBeNull()
})
})
describe('MWT cross-language pin (Python create_mwt)', () => {
const TOKEN = 'eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJzdWIiOiI0MiIsInN0YWZmIjp0cnVlLCJzdXBlciI6ZmFsc2UsInBrZXkiOiIwZTk5OGE5ZmYxNjkwNDYzN2EwM2QyZWEwZmJkYmY5NzQyOTdhOWQxYTVkMjViOGQ0Mjk0ZmE4ODIxMTVlNDU3IiwiYXVkIjoibWl6YW4iLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwfQ._V92JXiLSLXoyuSwbNvvJjwzgmczmC7dvX34kVSLIa8'
const PIN_SECRET = 'pin-test-secret-mwt'
test('decodes the Python-minted token', () => {
const p = decodeMwt(TOKEN, PIN_SECRET)
expect(p).not.toBeNull()
expect(p!.sub).toBe('42')
expect(p!.staff).toBe(true)
expect(p!.super).toBe(false)
expect(p!.pkey).toBe('0e998a9ff16904637a03d2ea0fbdbf974297a9d1a5d25b8d4294fa882115e457')
expect(p!.kid).toBe('v1')
expect(p!.aud).toBe('mizan')
})
test('identity from Python-minted token', () => {
const p = decodeMwt(TOKEN, PIN_SECRET)!
expect(identityFromMwt(p)).toEqual({
isAuthenticated: true,
isStaff: true,
isSuperuser: false,
id: 42,
})
})
})

View File

@@ -6,7 +6,6 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"PyJWT>=2.0", "PyJWT>=2.0",
"pydantic>=2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,3 +0,0 @@
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]

View File

@@ -1,27 +0,0 @@
from mizan_core.auth.authenticate import INVALID, AuthConfig, authenticate
from mizan_core.auth.jwt import (
JWTConfig,
JWTUser,
TokenPair,
TokenPayload,
create_access_token,
create_refresh_token,
create_token_pair,
decode_token,
refresh_tokens,
)
__all__ = [
"AuthConfig",
"authenticate",
"INVALID",
"JWTConfig",
"JWTUser",
"TokenPair",
"TokenPayload",
"create_access_token",
"create_refresh_token",
"create_token_pair",
"decode_token",
"refresh_tokens",
]

View File

@@ -1,53 +0,0 @@
"""
Token → identity resolution, shared by every adapter.
`authenticate(headers, config)` reads `X-Mizan-Token` (MWT) first, then
`Authorization: Bearer` (JWT), and returns an `Identity`, `None`, or the
`INVALID` sentinel.
The `INVALID` sentinel is load-bearing: when a token is PRESENT but bad, the
adapter must REJECT — never silently fall back to session auth (that would let
a forged/expired token degrade into anonymous-or-session access). `None` means
"no token offered" → the adapter may fall back to its own session identity.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
from mizan_core.auth.jwt import JWTConfig, JWTUser, decode_token
from mizan_core.identity import Identity
from mizan_core.mwt import MWTUser, decode_mwt
class _Invalid:
"""Sentinel: a token was presented but failed validation."""
def __repr__(self) -> str:
return "INVALID"
INVALID = _Invalid()
@dataclass(frozen=True)
class AuthConfig:
jwt: JWTConfig | None = None
mwt_secret: str | None = None
mwt_audience: str = "mizan"
def authenticate(headers: Mapping[str, str], config: AuthConfig) -> Identity | _Invalid | None:
"""Resolve identity from request headers. Returns Identity | INVALID | None."""
mwt = headers.get("X-Mizan-Token") or headers.get("x-mizan-token")
if mwt and config.mwt_secret:
payload = decode_mwt(mwt, config.mwt_secret, audience=config.mwt_audience)
return MWTUser(payload) if payload else INVALID
bearer = headers.get("Authorization") or headers.get("authorization") or ""
if bearer.startswith("Bearer ") and config.jwt:
payload = decode_token(bearer[7:], config.jwt, expected_type="access")
return JWTUser(payload) if payload else INVALID
return None

View File

@@ -1,137 +0,0 @@
"""
JWT access/refresh tokens — adapter-agnostic (PyJWT).
Config is injected (`JWTConfig`) rather than read from any framework's settings.
`validate_session` (the immediate-logout-revocation check) is Django-session-bound
and stays in the Django adapter; `refresh_tokens` takes a `session_validator`
callable so the core stays framework-free.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Callable, NamedTuple
import jwt
@dataclass(frozen=True)
class JWTConfig:
private_key: str
public_key: str
algorithm: str = "HS256"
access_token_expires_in: int = 300
refresh_token_expires_in: int = 604800
class TokenPair(NamedTuple):
access_token: str
refresh_token: str
expires_in: int
class TokenPayload(NamedTuple):
user_id: int | str
session_key: str
token_type: str
is_staff: bool
is_superuser: bool
exp: int
iat: int
class JWTUser:
"""Minimal `Identity` built from JWT claims — no DB query."""
def __init__(self, payload: TokenPayload):
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
self.pk = self.id
self.is_staff = payload.is_staff
self.is_superuser = payload.is_superuser
self.is_authenticated = True
self.is_anonymous = False
self.is_active = True
def __str__(self) -> str:
return f"JWTUser(id={self.id})"
def __repr__(self) -> str:
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
def _mint(user_id: int | str, session_key: str, token_type: str, ttl: int,
config: JWTConfig, is_staff: bool, is_superuser: bool) -> str:
now = int(time.time())
payload = {
"sub": str(user_id),
"sid": session_key,
"staff": is_staff,
"super": is_superuser,
"type": token_type,
"iat": now,
"exp": now + ttl,
}
return jwt.encode(payload, config.private_key, algorithm=config.algorithm)
def create_access_token(user_id, session_key, config: JWTConfig, *,
is_staff: bool = False, is_superuser: bool = False) -> str:
return _mint(user_id, session_key, "access", config.access_token_expires_in,
config, is_staff, is_superuser)
def create_refresh_token(user_id, session_key, config: JWTConfig, *,
is_staff: bool = False, is_superuser: bool = False) -> str:
return _mint(user_id, session_key, "refresh", config.refresh_token_expires_in,
config, is_staff, is_superuser)
def create_token_pair(user_id, session_key, config: JWTConfig, *,
is_staff: bool = False, is_superuser: bool = False) -> TokenPair:
return TokenPair(
access_token=create_access_token(user_id, session_key, config,
is_staff=is_staff, is_superuser=is_superuser),
refresh_token=create_refresh_token(user_id, session_key, config,
is_staff=is_staff, is_superuser=is_superuser),
expires_in=config.access_token_expires_in,
)
def decode_token(token: str, config: JWTConfig, expected_type: str | None = None) -> TokenPayload | None:
"""Decode + validate. None on invalid/expired token, or type mismatch."""
try:
payload = jwt.decode(token, config.public_key, algorithms=[config.algorithm])
except jwt.PyJWTError:
return None
if expected_type and payload.get("type") != expected_type:
return None
return TokenPayload(
user_id=payload["sub"],
session_key=payload["sid"],
token_type=payload["type"],
is_staff=payload.get("staff", False),
is_superuser=payload.get("super", False),
exp=payload["exp"],
iat=payload["iat"],
)
def refresh_tokens(
refresh_token: str,
config: JWTConfig,
session_validator: Callable[[str], bool] | None = None,
) -> TokenPair | None:
"""Exchange a refresh token for a new pair. None if invalid or the session is gone.
`session_validator(session_key) -> bool` lets the Django adapter enforce
immediate-logout revocation; omit it (or pass a always-True) where there is
no session store.
"""
payload = decode_token(refresh_token, config, expected_type="refresh")
if payload is None:
return None
if session_validator is not None and not session_validator(payload.session_key):
return None
return create_token_pair(payload.user_id, payload.session_key, config,
is_staff=payload.is_staff, is_superuser=payload.is_superuser)

View File

@@ -1,52 +0,0 @@
"""
Auth-guard evaluation — the adapter-agnostic core.
`enforce_auth` evaluates a function's `@client(auth=...)` requirement against an
`Identity` and raises `Unauthorized`/`Forbidden` on failure. A custom `auth=callable`
receives the adapter's NATIVE request (it may read request-specific state), passed
through opaquely — the core never introspects it.
"""
from __future__ import annotations
from typing import Any
from mizan_core.errors import Forbidden, InternalError, Unauthorized
from mizan_core.identity import Identity
def enforce_auth(
identity: Identity | None,
requirement: Any,
native_request: Any = None,
) -> None:
"""Raise `Unauthorized`/`Forbidden` if `identity` fails `requirement`; else return.
Requirement: None | True | "required" | "staff" | "superuser" | callable(native_request)->bool.
"""
if requirement is None:
return
if callable(requirement):
try:
if not requirement(native_request):
raise Forbidden("Access denied")
except PermissionError as e:
raise Forbidden(str(e) or "Access denied") from e
return
if not getattr(identity, "is_authenticated", False):
raise Unauthorized("Authentication required")
if requirement in (True, "required"):
return
if requirement == "staff":
if not getattr(identity, "is_staff", False):
raise Forbidden("Staff access required")
return
if requirement == "superuser":
if not getattr(identity, "is_superuser", False):
raise Forbidden("Superuser access required")
return
raise InternalError(f"Unknown auth requirement: {requirement!r}")

View File

@@ -487,10 +487,8 @@ def _create_server_function(
# Use function name directly # Use function name directly
name = fn.__name__ name = fn.__name__
# Extract type hints and signature. include_extras keeps `Annotated[...]` # Extract type hints and signature
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it hints = get_type_hints(fn)
# survives into the generated Input model.
hints = get_type_hints(fn, include_extras=True)
sig = inspect.signature(fn) sig = inspect.signature(fn)
params = list(sig.parameters.items()) params = list(sig.parameters.items())

View File

@@ -1,250 +0,0 @@
"""
The adapter-agnostic dispatch core.
Both `dispatch_call` (mutations/RPC) and `dispatch_context` (bundled reads) run
the full protocol: auth → input validation → execute (`await view.acall`, which
threadpools sync handlers) → serialize → resolve invalidation/merge → orchestrate
origin cache. They return a `DispatchResult` the adapter renders to its native
response. Errors raise `MizanError` (the adapter catches at its boundary).
The adapter owns native request parsing (multipart/JSON) and native response
construction; it hands the core a `DispatchRequest` carrying only what the core
reads, and renders what the core returns.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Literal
from pydantic import BaseModel, ValidationError
from pydantic_core import to_jsonable_python
from mizan_core.authguard import enforce_auth
from mizan_core.cache.backend import CacheBackend
from mizan_core.cache.keys import CONTEXT_KEY_PREFIX, derive_cache_key
from mizan_core.errors import (
BadRequest,
InternalError,
MizanError,
NotFound,
NotImplementedYet,
ValidationFailed,
)
from mizan_core.identity import Identity, user_id_of
from mizan_core.invalidation import (
format_invalidate_header,
resolve_invalidation,
resolve_merges,
)
from mizan_core.registry import get_context_groups, get_function
# ─── Request / result ───────────────────────────────────────────────────────
@dataclass
class DispatchRequest:
"""What the dispatch core reads. The adapter resolves `identity` (session OR
token) and parses `args`/`files`; `native_request` is an opaque passthrough
handed to `view_class(...)` and to `auth=callable`."""
identity: Identity | None = None
args: dict[str, Any] | None = None
files: dict[str, list[Any]] | None = None
native_request: Any = None
@dataclass
class DispatchResult:
kind: Literal["rpc", "view", "context"] = "rpc"
native_response: Any | None = None # view-path: the handler's own response
data: Any | None = None # rpc: serialized payload; context: bundle dict
body_bytes: bytes | None = None # context: canonical JSON to send/cache
cache_status: str | None = None # context: "HIT" | "MISS" | None
invalidate: list[Any] | None = None
merge: list[dict[str, Any]] | None = None
invalidate_header: str | None = None
# ─── Cache orchestration ────────────────────────────────────────────────────
class CacheOrchestrator:
"""Origin-side cache, backend + secret injected by the adapter (config seam)."""
def __init__(self, backend: CacheBackend | None, secret: str | None):
self.backend = backend
self.secret = secret
@property
def enabled(self) -> bool:
return self.backend is not None and bool(self.secret)
def get(self, context: str, params: dict[str, Any], user_id: str | None, rev: int) -> bytes | None:
if not self.enabled:
return None
return self.backend.get(derive_cache_key(self.secret, context, params, user_id, rev))
def put(self, context: str, params: dict[str, Any], value: bytes, user_id: str | None, rev: int) -> None:
if not self.enabled:
return
self.backend.set(derive_cache_key(self.secret, context, params, user_id, rev), value)
def purge(self, invalidate: list[Any], user_id: str | None) -> None:
if not self.enabled:
return
for entry in invalidate:
if isinstance(entry, str):
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{entry}:")
elif isinstance(entry, dict):
ctx = entry["context"]
params = entry.get("params")
if params:
self.backend.delete(derive_cache_key(self.secret, ctx, params, user_id, 0))
else:
self.backend.delete_by_prefix(f"{CONTEXT_KEY_PREFIX}{ctx}:")
# ─── Shared dispatch helpers ────────────────────────────────────────────────
def _resolve_function(fn_name: str) -> Any:
view_class = get_function(fn_name)
if view_class is None:
raise NotFound("Function not found")
if getattr(view_class, "_meta", {}).get("private"):
from mizan_core.errors import Forbidden
raise Forbidden("Function is not client-callable")
return view_class
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
"""Validate `input_data` against the function's Input model."""
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
return None
required = [name for name, f in input_cls.model_fields.items() if f.is_required()]
if not input_data:
if required:
raise ValidationFailed(
"Input validation failed",
details={"fields": {name: ["Field required"] for name in required}},
)
return input_cls()
if not isinstance(input_data, dict):
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
try:
return input_cls(**input_data)
except ValidationError as e:
raise ValidationFailed("Input validation failed", details={"errors": e.errors()}) from e
def _serialize(result: Any) -> Any:
return to_jsonable_python(result)
async def _run(view: Any, validated: Any) -> Any:
try:
return await view.acall(validated)
except NotImplementedError as e:
raise NotImplementedYet(str(e) or "Not implemented") from e
except MizanError:
raise
except Exception as e:
raise InternalError(str(e)) from e
def _canonical_bytes(data: Any) -> bytes:
return json.dumps(data, sort_keys=True).encode("utf-8")
# ─── Entry points ───────────────────────────────────────────────────────────
async def dispatch_call(req: DispatchRequest, fn_name: str, cache: CacheOrchestrator) -> DispatchResult:
"""Mutation / RPC dispatch."""
view_class = _resolve_function(fn_name)
meta = getattr(view_class, "_meta", {})
enforce_auth(req.identity, meta.get("auth"), req.native_request)
view = view_class(req.native_request)
validated = _validate_input(view.Input, req.args)
result = await _run(view, validated)
invalidate = resolve_invalidation(view_class, req.args)
header = format_invalidate_header(invalidate) if invalidate else None
if invalidate:
cache.purge(invalidate, user_id_of(req.identity))
if meta.get("view_path"):
# Handler returned its own native response; carry it through + the header.
return DispatchResult(kind="view", native_response=result,
invalidate=invalidate, invalidate_header=header)
serialized = _serialize(result)
return DispatchResult(
kind="rpc",
data=serialized,
invalidate=invalidate,
merge=resolve_merges(view_class, req.args, serialized),
invalidate_header=header,
)
def _effective_policy(fn_names: list[str]) -> tuple[int, int | bool]:
"""(effective_rev, effective_cache) across a context's functions."""
rev = 0
cache_policy: int | bool = True # True=forever, False=no-store, int=TTL
for fn_name in fn_names:
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
m = getattr(fn_cls, "_meta", {})
rev = max(rev, m.get("rev", 0))
fn_cache = m.get("cache", True)
if fn_cache is False:
return rev, False
if isinstance(fn_cache, int):
cache_policy = fn_cache if cache_policy is True else min(cache_policy, fn_cache)
return rev, cache_policy
async def dispatch_context(req: DispatchRequest, context_name: str, cache: CacheOrchestrator) -> DispatchResult:
"""Bundled context read with origin-cache get/put."""
groups = get_context_groups()
fn_names = groups.get(context_name)
if not fn_names:
raise NotFound(f"Context '{context_name}' not found")
params = req.args or {}
rev, cache_policy = _effective_policy(fn_names)
user_id = user_id_of(req.identity)
use_cache = cache.enabled and cache_policy is not False
if use_cache:
cached = cache.get(context_name, params, user_id, rev)
if cached is not None:
return DispatchResult(kind="context", body_bytes=cached, cache_status="HIT")
bundle: dict[str, Any] = {}
for fn_name in fn_names:
view_class = _resolve_function(fn_name)
enforce_auth(req.identity, getattr(view_class, "_meta", {}).get("auth"), req.native_request)
view = view_class(req.native_request)
validated = _validate_input(view.Input, {k: v for k, v in params.items() if _declares(view.Input, k)})
bundle[fn_name] = _serialize(await _run(view, validated))
body = _canonical_bytes(bundle)
if use_cache:
cache.put(context_name, params, body, user_id, rev)
return DispatchResult(kind="context", data=bundle, body_bytes=body,
cache_status="MISS" if use_cache else None)
def _declares(input_cls: Any, name: str) -> bool:
return bool(
input_cls and input_cls is not BaseModel
and getattr(input_cls, "model_fields", None)
and name in input_cls.model_fields
)

View File

@@ -1,58 +0,0 @@
"""
Canonical protocol-level error taxonomy.
Dispatch raises these typed exceptions; each backend adapter renders them to
its native response (Django `JsonResponse`, FastAPI exception handler, …). The
shared dispatch core never returns error envelopes — it raises, and the adapter
catches at its boundary.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
class ErrorCode(str, Enum):
NOT_FOUND = "NOT_FOUND"
BAD_REQUEST = "BAD_REQUEST"
VALIDATION_ERROR = "VALIDATION_ERROR"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
INTERNAL_ERROR = "INTERNAL_ERROR"
STATUS = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.NOT_IMPLEMENTED: 501,
ErrorCode.INTERNAL_ERROR: 500,
}
class MizanError(Exception):
"""Base for protocol-level dispatch errors."""
code: ErrorCode = ErrorCode.INTERNAL_ERROR
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = details
@property
def status_code(self) -> int:
return STATUS[self.code]
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701

View File

@@ -1,32 +0,0 @@
"""
The minimal identity contract the dispatch core reads.
Auth-guard evaluation and per-user cache scoping need exactly these four
attributes — nothing about how the identity was established. Django's session
`User`, `JWTUser`, `MWTUser`, and any token-user an adapter constructs all
satisfy this structurally; no inheritance required.
`get_all_permissions()` (Django ORM) is deliberately NOT here — the MWT
permission-key is a Django-side concern, and adding it would force every
adapter to implement a Django-shaped method.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class Identity(Protocol):
is_authenticated: bool
is_staff: bool
is_superuser: bool
@property
def pk(self) -> object | None: ... # str | int | None; cache scoping stringifies it
def user_id_of(identity: Identity | None) -> str | None:
"""The cache-scoping user id — `str(pk)`, or None for anonymous/no-pk."""
pk = getattr(identity, "pk", None)
return str(pk) if pk is not None else None

View File

@@ -1,174 +0,0 @@
"""
Server-driven invalidation + merge resolution — the adapter-agnostic core.
This is the canonical implementation (formerly housed in the Django executor).
Every adapter calls `resolve_invalidation` / `resolve_merges` / `format_invalidate_header`
so the wire shape is identical across backends.
Invalidation entries take one of two shapes:
- a bare context/function name string → broad invalidation
- {"context": name, "params": {...}} → scoped invalidation
Function-level `affects=` resolves to the function NAME as the key (v1 refetches
the whole context anyway).
"""
from __future__ import annotations
from typing import Any
from urllib.parse import quote
from pydantic import BaseModel
from mizan_core.registry import get_context_groups, get_function
from mizan_core.type_utils import types_match_for_merge
__all__ = ["resolve_invalidation", "resolve_merges", "format_invalidate_header"]
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
"""Classify an affects target → ("context", name, None) | ("function", name, ctx)."""
groups = get_context_groups()
if target_name in groups:
return ("context", target_name, None)
for ctx_name, fn_names in groups.items():
if target_name in fn_names:
return ("function", target_name, ctx_name)
# Unknown — treat as a context name (non-context fn, or not-yet-registered).
return ("context", target_name, None)
def _context_param_names(context_name: str) -> set[str]:
"""Union of Input field names across the functions in a context."""
param_names: set[str] = set()
for fn_name in get_context_groups().get(context_name, []):
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
input_cls = getattr(fn_cls, "Input", None)
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
param_names.update(input_cls.model_fields.keys())
return param_names
def resolve_invalidation(
view_class: type | None,
input_data: dict[str, Any] | None = None,
) -> list[str | dict[str, Any]] | None:
"""Three-tier auto-scoping over `@client(affects=...)`. None if nothing to invalidate.
Tier 1: arg-name matching against the context's params → scoped entry.
Tier 2: auth inference — Edge-side, not handled here.
Tier 3: broad fallback — bare name.
"""
if view_class is None:
return None
affects = getattr(view_class, "_meta", {}).get("affects")
if not affects:
return None
result: list[str | dict[str, Any]] = []
seen: set[str] = set()
for target in affects:
if target["type"] == "context":
target_name = target["name"]
elif target["type"] == "function" and target.get("context"):
target_name = target["name"]
else:
continue
if target_name in seen:
continue
seen.add(target_name)
resolved = _resolve_affects_target(target_name)
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
if input_data and ctx_for_params:
context_params = _context_param_names(ctx_for_params)
matched = {k: v for k, v in input_data.items() if k in context_params}
if matched:
result.append({"context": target_name, "params": matched})
continue
result.append(target_name)
return result or None
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
"""The unique function-name slot in a context whose return type matches the mutation output."""
if mutation_output is None:
return None
matches: list[str] = []
for fn_name in get_context_groups().get(context_name, []):
fn_cls = get_function(fn_name)
if fn_cls is None:
continue
fn_output = getattr(fn_cls, "Output", None)
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
matches.append(fn_name)
return matches[0] if len(matches) == 1 else None
def resolve_merges(
view_class: type | None,
input_data: dict[str, Any] | None,
result_data: Any,
) -> list[dict[str, Any]] | None:
"""Build the `merge` list from `@client(merge=...)`. None when no targets resolve.
Each entry is `{context, slot, value, params?}`; `slot` is the context-function
whose return type matches the mutation output (server-side type-checked routing,
no client shape inference). Ambiguous/unmatched targets are dropped.
"""
if view_class is None:
return None
targets = getattr(view_class, "_meta", {}).get("merge") or []
if not targets:
return None
mutation_output = getattr(view_class, "Output", None)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for ctx_name in targets:
if ctx_name in seen:
continue
seen.add(ctx_name)
slot = _resolve_merge_slot(ctx_name, mutation_output)
if slot is None:
continue
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
if input_data:
matched = {k: v for k, v in input_data.items() if k in _context_param_names(ctx_name)}
if matched:
entry["params"] = matched
out.append(entry)
return out or None
def format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
"""Serialize invalidation targets to the `X-Mizan-Invalidate` header value.
Comma-separated contexts; semicolon-separated URL-encoded params per context.
["user"] → "user"
["user", "notifications"] → "user, notifications"
[{"context": "user", "params": {"user_id": 5}}] → "user;user_id=5"
[{"context": "search", "params": {"q": "hello world"}}] → "search;q=hello%20world"
"""
parts: list[str] = []
for entry in invalidate:
if isinstance(entry, str):
parts.append(entry)
elif isinstance(entry, dict):
ctx = entry["context"]
params = entry.get("params", {})
if params:
param_str = ";".join(
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
for k, v in sorted(params.items())
)
parts.append(f"{ctx};{param_str}")
else:
parts.append(ctx)
return ", ".join(parts)

View File

@@ -17,7 +17,6 @@ KDL grammar — locked contract:
| list { <type-child> } | list { <type-child> }
| optional { <type-child> } | optional { <type-child> }
| enum "<v1>" "<v2>" ... | enum "<v1>" "<v2>" ...
| upload max-size=<int>? { content-type "<mime>" ... }
} }
... ...
} }
@@ -73,7 +72,6 @@ from pydantic_core import PydanticUndefined
from mizan_core.registry import get_all_functions, get_context_groups, get_function from mizan_core.registry import get_all_functions, get_context_groups, get_function
from mizan_core.type_utils import extract_list_element, extract_optional from mizan_core.type_utils import extract_list_element, extract_optional
from mizan_core.upload import File, classify_upload
__all__ = ["build_ir"] __all__ = ["build_ir"]
@@ -246,34 +244,6 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
_emit_type_child(alias_block, annotation, named_types) _emit_type_child(alias_block, annotation, named_types)
def _emit_upload_node(block: _Block, spec: File | None) -> None:
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
props: dict[str, str] = {}
if spec is not None and spec.max_size is not None:
props["max-size"] = repr(spec.max_size)
if spec is not None and spec.content_types:
with block.node("upload", **props) as up:
for ct in spec.content_types:
up.leaf("content-type", _kdl_string(ct))
else:
block.leaf("upload", **props)
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
if is_optional and is_list:
with block.node("optional") as opt, opt.node("list") as lst:
_emit_upload_node(lst, spec)
elif is_optional:
with block.node("optional") as opt:
_emit_upload_node(opt, spec)
elif is_list:
with block.node("list") as lst:
_emit_upload_node(lst, spec)
else:
_emit_upload_node(block, spec)
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None: def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
"""Emit a `struct { field ... }` block for a Pydantic model.""" """Emit a `struct { field ... }` block for a Pydantic model."""
with block.node("struct") as struct_block: with block.node("struct") as struct_block:
@@ -289,10 +259,6 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
props["default"] = _kdl_value(default) props["default"] = _kdl_value(default)
with struct_block.node("field", _kdl_string(field_name), **props) as field_block: with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
is_upload, is_list, is_optional, spec = classify_upload(field_info)
if is_upload:
_emit_upload_child(field_block, is_list, is_optional, spec)
else:
_emit_type_child(field_block, field_info.annotation, named_types) _emit_type_child(field_block, field_info.annotation, named_types)

View File

@@ -1,216 +0,0 @@
"""
Mizan Upload — first-class binary input for ``@client`` functions.
``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
parameter makes a function multipart-aware end to end: the generated client
switches that call to ``multipart/form-data``, and each backend adapter parses
the file part and binds a uniform :class:`UploadedFile` into the function's
``Input``. Constraints declared via :class:`File` are enforced at dispatch.
from typing import Annotated
from mizan import client, Upload, File
@client(affects=UserContext)
def set_avatar(
request,
user_id: int,
avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
) -> dict:
avatar.save(f"/media/{user_id}.png")
return {"ok": True}
Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
carries the declarative constraints.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
from mizan_core.type_utils import extract_list_element, extract_optional
__all__ = [
"Upload",
"UploadedFile",
"File",
"parse_size",
"validate_upload",
"classify_upload",
"upload_fields",
"bind_uploads",
]
_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}
def parse_size(value: int | str) -> int:
"""Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
if isinstance(value, int):
return value
s = value.strip().upper()
for unit, mult in _SIZE_UNITS.items():
if s.endswith(unit):
return int(float(s[: -len(unit)].strip()) * mult)
return int(s)
@dataclass(frozen=True)
class File:
"""Declarative constraints for an ``Upload`` field, placed in ``Annotated``.
``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
``content_types`` is a list of allowed MIME types; an entry ending in
``/*`` (e.g. ``"image/*"``) matches any subtype.
"""
max_size: int | str | None = None
content_types: tuple[str, ...] | None = None
def __post_init__(self) -> None:
if self.max_size is not None:
object.__setattr__(self, "max_size", parse_size(self.max_size))
if self.content_types is not None and not isinstance(self.content_types, tuple):
object.__setattr__(self, "content_types", tuple(self.content_types))
class UploadedFile:
"""Uniform file handle handed to ``@client`` functions, adapter-agnostic.
Backends construct this from their native upload object (Django
``UploadedFile``, Starlette ``UploadFile``) so a function body stays
portable across adapters.
"""
__slots__ = ("filename", "content_type", "_data")
def __init__(self, filename: str | None, content_type: str | None, data: bytes):
self.filename = filename
self.content_type = content_type
self._data = data
@property
def size(self) -> int:
return len(self._data)
def read(self) -> bytes:
return self._data
def save(self, path: str | os.PathLike) -> None:
with open(path, "wb") as fh:
fh.write(self._data)
class Upload:
"""Pydantic-composable marker type for a binary file input.
At validation time it accepts any :class:`UploadedFile` (the adapter has
already bound the multipart part). The IR emitter recognizes Upload-typed
fields and emits an ``upload`` node.
"""
@classmethod
def __get_pydantic_core_schema__(
cls, source: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
return core_schema.no_info_plain_validator_function(cls._validate)
@staticmethod
def _validate(value: Any) -> UploadedFile:
if isinstance(value, UploadedFile):
return value
raise ValueError("expected an uploaded file")
def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
if not content_type:
return False
for ct in allowed:
if ct == content_type:
return True
if ct.endswith("/*") and content_type.startswith(ct[:-1]):
return True
return False
def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
"""Enforce declared constraints. Returns an error message, or ``None`` if ok."""
if spec is None:
return None
if spec.max_size is not None and file.size > spec.max_size:
return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
return None
# ─── Field classification + binding (shared by every backend adapter) ─────────
def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
"""Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
if hasattr(annotation, "__metadata__"):
spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
return annotation.__origin__, spec
return annotation, None
def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
"""Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.
Composes through ``Optional[...]``, ``list[...]``, and
``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
wherever it appears (Pydantic lifts a top-level marker into
``field_info.metadata``; nested markers stay inside the annotation).
"""
spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
ann = field_info.annotation
ann, s = _strip_annotated_meta(ann); spec = spec or s
ann, is_optional = extract_optional(ann)
ann, s = _strip_annotated_meta(ann); spec = spec or s
elem = extract_list_element(ann)
is_list = elem is not None
if is_list:
ann, s = _strip_annotated_meta(elem); spec = spec or s
return ann is Upload, is_list, is_optional, spec
def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
"""Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
out: dict[str, tuple[bool, File | None]] = {}
for name, field_info in model.model_fields.items():
is_upload, is_list, _is_opt, spec = classify_upload(field_info)
if is_upload:
out[name] = (is_list, spec)
return out
def bind_uploads(
input_cls: Any,
args: dict[str, Any],
files: dict[str, list[UploadedFile]],
) -> str | None:
"""Place uploaded files into ``args`` by field name, enforcing constraints.
Mutates ``args`` in place. ``files`` maps a field name to the parts received
for it (an array field receives several). Returns an error message on the
first constraint violation, else ``None``. Absent files are left for
Pydantic's required/optional handling.
"""
for fname, (is_list, spec) in upload_fields(input_cls).items():
bucket = files.get(fname) or []
if not bucket:
continue
for f in bucket:
err = validate_upload(f, spec)
if err is not None:
return f"{fname}: {err}"
args[fname] = list(bucket) if is_list else bucket[0]
return None

View File

@@ -1,147 +0,0 @@
"""Unit tests for the adapter-agnostic dispatch core."""
import asyncio
import pytest
from pydantic import BaseModel
from mizan_core.auth import AuthConfig, JWTConfig, INVALID, authenticate, create_access_token
from mizan_core.authguard import enforce_auth
from mizan_core.client.function import client
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
from mizan_core.errors import Forbidden, Unauthorized
from mizan_core.invalidation import format_invalidate_header, resolve_invalidation
from mizan_core.registry import clear_registry, register
class Ident:
def __init__(self, authed=True, staff=False, su=False, pk=1):
self.is_authenticated = authed
self.is_staff = staff
self.is_superuser = su
self.pk = pk
# ─── authguard ──────────────────────────────────────────────────────────────
def test_auth_required_anonymous():
with pytest.raises(Unauthorized):
enforce_auth(None, True)
def test_auth_required_authenticated():
enforce_auth(Ident(), True) # no raise
def test_auth_staff_denied_then_allowed():
with pytest.raises(Forbidden):
enforce_auth(Ident(staff=False), "staff")
enforce_auth(Ident(staff=True), "staff")
def test_auth_superuser():
with pytest.raises(Forbidden):
enforce_auth(Ident(su=False), "superuser")
enforce_auth(Ident(su=True), "superuser")
def test_auth_callable_false_and_raise():
with pytest.raises(Forbidden):
enforce_auth(Ident(), lambda r: False)
with pytest.raises(Forbidden, match="custom"):
enforce_auth(Ident(), lambda r: (_ for _ in ()).throw(PermissionError("custom")))
# ─── authenticate / INVALID sentinel ────────────────────────────────────────
def _cfg():
return AuthConfig(jwt=JWTConfig(private_key="k" * 32, public_key="k" * 32))
def test_authenticate_jwt_ok():
cfg = _cfg()
tok = create_access_token("7", "sess", cfg.jwt, is_staff=True)
ident = authenticate({"Authorization": f"Bearer {tok}"}, cfg)
assert ident.pk == 7 and ident.is_staff and ident.is_authenticated
def test_authenticate_bad_token_is_invalid_sentinel():
assert authenticate({"Authorization": "Bearer garbage"}, _cfg()) is INVALID
def test_authenticate_no_token_is_none():
assert authenticate({}, _cfg()) is None
# ─── invalidation + header ──────────────────────────────────────────────────
def test_invalidation_three_tier_and_header():
clear_registry()
UserCtx = "user"
class Out(BaseModel):
ok: bool
@client(context=UserCtx)
def user_profile(request, user_id: int) -> Out:
return Out(ok=True)
@client(affects=UserCtx)
def update_profile(request, user_id: int, name: str) -> Out:
return Out(ok=True)
register(user_profile, "user_profile")
register(update_profile, "update_profile")
# Tier 1: user_id matches context param → scoped
inv = resolve_invalidation(update_profile, {"user_id": 5, "name": "x"})
assert inv == [{"context": "user", "params": {"user_id": 5}}]
assert format_invalidate_header(inv) == "user;user_id=5"
# Tier 3: no matching param → broad
inv2 = resolve_invalidation(update_profile, {"name": "x"})
assert inv2 == ["user"]
clear_registry()
# ─── dispatch_call end to end ───────────────────────────────────────────────
def test_dispatch_call_auth_and_invalidation():
clear_registry()
class Out(BaseModel):
ok: bool
@client(context="user")
def user_profile(request, user_id: int) -> Out:
return Out(ok=True)
@client(affects="user", auth="staff")
def secure_update(request, user_id: int) -> Out:
return Out(ok=True)
register(user_profile, "user_profile")
register(secure_update, "secure_update")
cache = CacheOrchestrator(None, None)
# non-staff rejected
with pytest.raises(Forbidden):
asyncio.run(dispatch_call(
DispatchRequest(identity=Ident(staff=False), args={"user_id": 1}),
"secure_update", cache,
))
# staff passes, invalidation resolved
res = asyncio.run(dispatch_call(
DispatchRequest(identity=Ident(staff=True), args={"user_id": 1}),
"secure_update", cache,
))
assert res.kind == "rpc" and res.data == {"ok": True}
assert res.invalidate == [{"context": "user", "params": {"user_id": 1}}]
assert res.invalidate_header == "user;user_id=1"
clear_registry()

2445
cores/mizan-rust-ssr/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
[package]
name = "mizan-rust-ssr"
version = "0.1.0"
edition = "2021"
description = "Mizan SSR engine — embeds a deno_core V8 runtime (with deno_web) to render the build-time JS bundle to HTML in-process. No node, no bun, at serve time."
license = "Elastic-2.0"
[lib]
path = "src/lib.rs"
# deno_core + deno_web are added via `cargo add` so the resolver pins a
# version-matched pair (deno_web constrains deno_core); the web-platform
# globals react-dom/server.browser needs (TextEncoder, timers, MessagePort,
# streams) come from deno_web as real implementations, not hand-rolled shims.
[dependencies]
anyhow = "1.0.102"
deno_core = "0.403.0"
deno_web = "0.281.0"
deno_webidl = "0.250.0"
[dev-dependencies]
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,133 @@
//! Mizan SSR engine.
//!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
//! web-platform implementations, not shims (a partial polyfill is
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! per-render data crosses as a `v8::json::parse`d value passed as a function
//! argument, so a prop string has no source to break out of — code injection
//! is structurally absent, not filtered.
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use deno_core::{v8, JsRuntime, RuntimeOptions};
use deno_web::{BlobStore, InMemoryBroadcastChannel};
/// Install the web-platform globals react-dom touches at module-init, pulled
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
/// registers these as lazy modules and installs nothing on `globalThis` until
/// asked — this is the minimal bootstrap that asks.
const INSTALL_WEB_GLOBALS: &str = r#"{
const lazy = (s) => Deno.core.loadExtScript(s);
const mp = lazy("ext:deno_web/13_message_port.js");
globalThis.MessageChannel = mp.MessageChannel;
globalThis.MessagePort = mp.MessagePort;
const te = lazy("ext:deno_web/08_text_encoding.js");
globalThis.TextEncoder = te.TextEncoder;
globalThis.TextDecoder = te.TextDecoder;
}"#;
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread).
pub struct SsrEngine {
runtime: JsRuntime,
}
impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(BlobStore::default()),
None, // maybe_location
false, // enable_css_parser_features
InMemoryBroadcastChannel::default(),
),
],
..Default::default()
});
runtime
.execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
.context("installing web-platform globals")?;
runtime
.execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?;
Ok(Self { runtime })
}
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
/// is a JSON object string; it is parsed to a V8 value and passed as an
/// argument — never spliced into evaluated source.
pub fn render(&mut self, props_json: &str) -> Result<String> {
deno_core::scope!(scope, &mut self.runtime);
let context = scope.get_current_context();
let global = context.global(scope);
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
let func_val = global
.get(scope, key.into())
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
let func: v8::Local<v8::Function> = func_val
.try_into()
.map_err(|_| anyhow!("renderApp is not a function"))?;
let props_str = v8::String::new(scope, props_json).context("intern props")?;
let props = v8::json::parse(scope, props_str)
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
let recv = v8::undefined(scope).into();
let result = func
.call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
}
#[tokio::test]
async fn props_cannot_inject_code() {
// A prop value that would break out of a string-built `renderApp(...)`
// call. Through the value-call path it is inert data: it reaches the
// component as a string, never as source.
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("fixture bundle");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
.expect("render");
// The payload rendered as text; it did not execute.
assert!(html.contains("__pwned"));
}
}

View File

@@ -0,0 +1,3 @@
node_modules/
package-lock.json
bundle.js

View File

@@ -0,0 +1,7 @@
import { createElement } from "react"
// A trivial component: props in, element out. The keystone only needs to prove
// a real React tree renders to HTML inside a bare JS context.
export function Hello({ name }) {
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
}

View File

@@ -0,0 +1,8 @@
import { renderToStaticMarkup } from "react-dom/server.browser"
import { createElement } from "react"
import { Hello } from "./Hello.js"
// The bundle exposes one global the embedded engine calls. No module system at
// runtime — the engine receives a bare script that defines `renderApp`. This is
// the production shape in miniature: build-time bundle, runtime eval.
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))

View File

@@ -0,0 +1,7 @@
{
"dependencies": {
"esbuild": "^0.28.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}

View File

@@ -0,0 +1,29 @@
// Proxy for the embedded-V8 runtime: a bare global context with no Node
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
// (bundle defines a global render fn over a bare context) does not.
const fs = require("fs")
const vm = require("vm")
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
// The minimal host globals React's bundle touches at init / sync render. The
// rusty_v8 engine must provide the same set — this list is the spec for it.
const sandbox = {
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
TextEncoder, TextDecoder,
}
sandbox.globalThis = sandbox
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const html = sandbox.renderApp({ name: "World" })
console.log("RENDERED:", html)
const expected = '<div id="greeting">Hello, World!</div>'
if (html !== expected) {
console.error("MISMATCH — expected:", expected)
process.exit(1)
}
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")

View File

@@ -0,0 +1,54 @@
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
//!
//! React Server Components and the Flight serialization protocol carry
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
//! attacker reaches prototype-pollution → RCE.
//!
//! Mizan renders **synchronously from props** — data is fetched server-side
//! through the AFI and passed in, never deserialized from a client payload — so
//! it sits structurally outside that attack surface. This test keeps it there:
//! it goes red the instant any RSC / Flight / streaming surface enters the
//! authored SSR source or its dependencies. Absence is not enough; this is the
//! forcing function that makes re-entry loud.
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
const FORBIDDEN: &[&str] = &[
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
"react-server-dom",
"renderToReadableStream",
"renderToPipeableStream",
"createFromReadableStream",
"createFromFetch",
"use server",
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
"next/",
"nuxt",
"@sveltejs/kit",
"sveltekit",
];
const SCANNED: &[&str] = &[
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/entry.js"),
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/Hello.js"),
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/package.json"),
];
#[test]
fn ssr_has_no_rsc_or_flight_surface() {
for path in SCANNED {
let Ok(src) = std::fs::read_to_string(path) else {
continue; // a generated/optional file absent is fine; authored source is the point
};
for needle in FORBIDDEN {
assert!(
!src.contains(needle),
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
classic renderToString-family only, rendered synchronously from props.",
);
}
}
}

View File

@@ -386,18 +386,6 @@ async function resolveHeaders(): Promise<Record<string, string>> {
} }
} }
/** Browser-safe `File` check — `File` is undefined under Node/SSR. */
function isFile(value: unknown): boolean {
return typeof File !== 'undefined' && value instanceof File
}
/** True when any arg is a file (or an array containing a file). */
function hasFileArg(args: Record<string, any>): boolean {
return Object.values(args).some(
(v) => isFile(v) || (Array.isArray(v) && v.some(isFile)),
)
}
/** /**
* Default Mizan transport — POST `${baseUrl}/call/` and GET * Default Mizan transport — POST `${baseUrl}/call/` and GET
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`, * `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
@@ -409,38 +397,13 @@ export function httpTransport(): MizanTransport {
return { return {
async call(functionName, args) { async call(functionName, args) {
const headers = await resolveHeaders() const headers = await resolveHeaders()
// File-typed args switch the call to multipart/form-data: `fn` and a
// JSON `args` part for the non-file fields, plus one part per file
// (an array field repeats its part). Otherwise JSON as usual. The
// server reconstructs the args dict by merging the file parts back in.
let body: BodyInit
if (hasFileArg(args)) {
const form = new FormData()
form.append('fn', functionName)
const jsonArgs: Record<string, any> = {}
for (const [key, value] of Object.entries(args)) {
if (isFile(value)) {
form.append(key, value)
} else if (Array.isArray(value) && value.some(isFile)) {
for (const item of value) form.append(key, item as Blob)
} else {
jsonArgs[key] = value
}
}
form.append('args', JSON.stringify(jsonArgs))
body = form
// Content-Type is set by the browser (with the multipart boundary).
} else {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
body = JSON.stringify({ fn: functionName, args })
}
const res = await fetch(`${config.baseUrl}/call/`, { const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST', method: 'POST',
headers, headers,
credentials: 'same-origin', credentials: 'same-origin',
body, body: JSON.stringify({ fn: functionName, args }),
}) })
if (!res.ok) throw new MizanError(res.status, await res.text()) if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json() return res.json()

View File

@@ -165,7 +165,6 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression) .map(ts_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .join(" | "),
TypeShape::Upload(_) => "File".to_string(),
} }
} }

View File

@@ -118,9 +118,6 @@ fn py_type_expression(shape: &TypeShape) -> String {
.map(py_type_expression) .map(py_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .join(" | "),
// The Python (PyO3) client is a consumer, not an upload origin; a file
// input surfaces as raw bytes on this target.
TypeShape::Upload(_) => "bytes".to_string(),
} }
} }

View File

@@ -440,8 +440,5 @@ fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
// Value so the consumer can match on the runtime variant. // Value so the consumer can match on the runtime variant.
"serde_json::Value".to_string() "serde_json::Value".to_string()
} }
// The Rust adapter does not yet wire multipart; a file input surfaces
// as raw bytes until upload dispatch lands on this target.
TypeShape::Upload(_) => "Vec<u8>".to_string(),
} }
} }

View File

@@ -296,6 +296,5 @@ fn ts_type_expression(shape: &TypeShape) -> String {
.map(ts_type_expression) .map(ts_type_expression)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" | "), .join(" | "),
TypeShape::Upload(_) => "File".to_string(),
} }
} }

View File

@@ -46,15 +46,6 @@ pub enum TypeShape {
Enum(Vec<String>), Enum(Vec<String>),
/// Multi-arm union with two or more non-null branches. /// Multi-arm union with two or more non-null branches.
Union(Vec<TypeShape>), Union(Vec<TypeShape>),
/// Binary file input. Carries the declarative `File(...)` constraints.
Upload(UploadConstraints),
}
#[derive(Debug, Clone, Default)]
pub struct UploadConstraints {
pub max_size: Option<u64>,
pub content_types: Vec<String>,
} }
@@ -282,7 +273,6 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))), "list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))), "optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
"enum" => Ok(TypeShape::Enum(parse_string_args(node))), "enum" => Ok(TypeShape::Enum(parse_string_args(node))),
"upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))),
"union" => { "union" => {
let children = node.children() let children = node.children()
.ok_or_else(|| anyhow!("union: missing children"))?; .ok_or_else(|| anyhow!("union: missing children"))?;
@@ -295,20 +285,6 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
} }
fn parse_upload_constraints(node: &KdlNode) -> UploadConstraints {
let max_size = node.entry("max-size")
.and_then(|e| e.value().as_integer())
.map(|i| i as u64);
let content_types = node.children()
.map(|children| children.nodes().iter()
.filter(|n| n.name().value() == "content-type")
.filter_map(|n| first_string_arg(n).ok())
.collect())
.unwrap_or_default();
UploadConstraints { max_size, content_types }
}
fn parse_function(node: &KdlNode) -> Result<MizanFunction> { fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
let name = first_string_arg(node) let name = first_string_arg(node)
.context("`function` requires a name as its first argument")?; .context("`function` requires a name as its first argument")?;

View File

@@ -1,79 +0,0 @@
//! Upload type-shape lowers to TS `File` across cardinalities. Separate from
//! the byte-parity baselines (which mustn't carry an upload field — the
//! three-way AFI parity gate includes the Rust adapter, which doesn't wire
//! uploads yet).
use std::path::PathBuf;
use mizan_codegen::config::{Config, SourceConfig};
use mizan_codegen::emit::stage1::Stage1;
use mizan_codegen::emit::CodegenTarget;
use mizan_codegen::fetch::parse_ir_from_str;
const UPLOAD_IR: &str = r#"
type "SetAvatarInput" {
struct {
field "user_id" {
primitive "integer"
}
field "avatar" {
upload max-size=5242880 {
content-type "image/png"
content-type "image/jpeg"
}
}
field "photos" {
list {
upload
}
}
field "thumb" required=#false {
optional {
upload
}
}
}
}
type "setAvatarOutput" {
alias {
primitive "string"
}
}
function "set_avatar" {
camel "setAvatar"
has-input #true
input "SetAvatarInput"
output "setAvatarOutput"
transport "http"
affects "user"
}
"#;
fn cfg() -> Config {
Config {
project_id: None,
output: PathBuf::from("/tmp"),
targets: vec!["stage1".to_string()],
source: SourceConfig { fastapi: None, django: None, rust: None, script: None },
rust_kernel: None,
rust_crate_name: None,
}
}
#[test]
fn upload_fields_lower_to_file_type() {
let ir = parse_ir_from_str(UPLOAD_IR).expect("upload IR parses");
let files = Stage1.emit(&ir, &cfg());
let types = files
.iter()
.find(|f| f.rel_path.to_string_lossy().contains("types.ts"))
.expect("types.ts emitted");
let src = &types.content;
assert!(src.contains("avatar: File"), "required upload → File:\n{src}");
assert!(src.contains("File[]"), "list[upload] → File[]:\n{src}");
assert!(src.contains("File | null"), "optional upload → File | null:\n{src}");
}