From ae684a36cbeb8437d09bd3d46b708819ea1a8621 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 4 Jun 2026 14:59:53 -0400 Subject: [PATCH] Restore approved state (tree of 4effcc7 "Added LICENSE") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/publish-django.yaml | 2 +- .gitea/workflows/publish-react.yaml | 2 +- .gitignore | 3 - CLAUDE.md | 472 +++++++++++++++ ISSUES.md | 3 +- Makefile | 19 +- README.md | 116 ++-- ROADMAP.md | 2 +- backends/mizan-django/src/mizan/__init__.py | 5 - .../src/mizan/cache/KNOWN_ISSUES.md | 28 +- .../mizan-django/src/mizan/client/executor.py | 277 +++++++-- .../mizan-django/src/mizan/export/__init__.py | 148 ++++- backends/mizan-django/src/mizan/jwt/tokens.py | 254 ++++++-- .../mizan-django/src/mizan/ssr/backend.py | 2 +- .../mizan-django/src/mizan}/ssr/bridge.py | 9 +- .../mizan-django/src/mizan/tests/test_auth.py | 4 +- .../src/mizan/tests/test_upload.py | 73 --- backends/mizan-fastapi/pyproject.toml | 5 - .../src/mizan_fastapi/__init__.py | 60 +- .../mizan-fastapi/src/mizan_fastapi/auth.py | 54 -- .../mizan-fastapi/src/mizan_fastapi/config.py | 80 --- .../src/mizan_fastapi/executor.py | 294 ++++++++-- .../src/mizan_fastapi/forms/__init__.py | 245 -------- .../src/mizan_fastapi/forms/schemas.py | 77 --- .../src/mizan_fastapi/manifest.py | 98 ---- .../mizan-fastapi/src/mizan_fastapi/router.py | 236 ++------ .../mizan-fastapi/src/mizan_fastapi/shapes.py | 307 ---------- .../mizan-fastapi/src/mizan_fastapi/ssr.py | 80 --- .../mizan-fastapi/tests/test_edge_manifest.py | 167 ------ backends/mizan-fastapi/tests/test_forms.py | 145 ----- backends/mizan-fastapi/tests/test_parity.py | 98 ---- backends/mizan-fastapi/tests/test_shapes.py | 269 --------- backends/mizan-fastapi/tests/test_ssr.py | 138 ----- backends/mizan-fastapi/tests/test_upload.py | 71 --- .../mizan-fastapi/tests/test_websocket.py | 145 ----- backends/mizan-rust-axum/Cargo.lock | 312 ---------- backends/mizan-rust-axum/Cargo.toml | 10 +- backends/mizan-rust-axum/src/forms.rs | 89 --- backends/mizan-rust-axum/src/handlers.rs | 435 ++------------ backends/mizan-rust-axum/src/lib.rs | 72 +-- backends/mizan-rust-axum/src/ssr.rs | 50 -- backends/mizan-rust-axum/src/state.rs | 106 ---- backends/mizan-rust-axum/src/ws.rs | 174 ------ backends/mizan-rust-axum/tests/behavior.rs | 422 ------------- backends/mizan-tauri/Cargo.lock | 101 ++-- backends/mizan-tauri/Cargo.toml | 5 - backends/mizan-tauri/src/lib.rs | 499 +++------------- backends/mizan-tauri/src/ssr.rs | 67 --- backends/mizan-tauri/tests/behavior.rs | 370 ------------ backends/mizan-ts/bun.lock | 16 - backends/mizan-ts/package.json | 6 +- backends/mizan-ts/src/decorator.ts | 107 ++-- backends/mizan-ts/src/dispatch.ts | 131 +---- backends/mizan-ts/src/forms.ts | 170 ------ backends/mizan-ts/src/identity.ts | 22 - backends/mizan-ts/src/index.ts | 50 +- backends/mizan-ts/src/ir/build.ts | 409 ------------- backends/mizan-ts/src/ir/index.ts | 17 - backends/mizan-ts/src/ir/types.ts | 70 --- backends/mizan-ts/src/session.ts | 46 -- backends/mizan-ts/src/shapes.ts | 78 --- backends/mizan-ts/src/ssr.ts | 216 ------- backends/mizan-ts/src/token.ts | 264 --------- backends/mizan-ts/src/types.ts | 35 +- backends/mizan-ts/src/upload.ts | 143 ----- backends/mizan-ts/src/websocket.ts | 116 ---- backends/mizan-ts/tests/auth.test.ts | 163 ------ backends/mizan-ts/tests/fixtures/Hello.tsx | 6 - .../mizan-ts/tests/fixtures/stub-worker.mjs | 53 -- backends/mizan-ts/tests/ir-fixture.ts | 149 ----- backends/mizan-ts/tests/ir.test.ts | 159 ----- backends/mizan-ts/tests/shapes-forms.test.ts | 167 ------ backends/mizan-ts/tests/ssr.test.ts | 101 ---- backends/mizan-ts/tests/token.test.ts | 253 -------- backends/mizan-ts/tests/transport.test.ts | 131 ----- backends/mizan-ts/tests/upload.test.ts | 163 ------ cores/mizan-python/pyproject.toml | 1 - cores/mizan-python/src/mizan_core/__init__.py | 3 - .../src/mizan_core/auth/__init__.py | 27 - .../src/mizan_core/auth/authenticate.py | 53 -- cores/mizan-python/src/mizan_core/auth/jwt.py | 137 ----- .../mizan-python/src/mizan_core/authguard.py | 52 -- .../src/mizan_core/client/function.py | 6 +- cores/mizan-python/src/mizan_core/dispatch.py | 250 -------- cores/mizan-python/src/mizan_core/errors.py | 58 -- cores/mizan-python/src/mizan_core/identity.py | 32 - .../src/mizan_core/invalidation.py | 174 ------ cores/mizan-python/src/mizan_core/ir.py | 36 +- cores/mizan-python/src/mizan_core/manifest.py | 161 ----- cores/mizan-python/src/mizan_core/registry.py | 15 +- .../src/mizan_core/ssr/__init__.py | 12 - cores/mizan-python/src/mizan_core/upload.py | 216 ------- .../mizan-python/tests/test_dispatch_core.py | 147 ----- cores/mizan-rust-macros/src/function.rs | 60 +- cores/mizan-rust-macros/src/shape.rs | 22 - cores/mizan-rust/Cargo.lock | 108 ---- cores/mizan-rust/Cargo.toml | 3 - cores/mizan-rust/src/auth.rs | 552 ------------------ cores/mizan-rust/src/cache.rs | 272 --------- cores/mizan-rust/src/ir.rs | 8 - cores/mizan-rust/src/kdl.rs | 25 +- cores/mizan-rust/src/lib.rs | 22 +- cores/mizan-rust/src/manifest.rs | 190 ------ cores/mizan-rust/src/runtime.rs | 69 --- cores/mizan-rust/src/shapes.rs | 146 ----- cores/mizan-rust/src/ssr.rs | 268 --------- cores/mizan-rust/src/traits.rs | 6 - cores/mizan-rust/src/upload.rs | 72 --- cores/mizan-rust/tests/cache_keys_pin.rs | 120 ---- .../mizan-rust/tests/invalidate_header_pin.rs | 90 --- cores/mizan-rust/tests/shapes_manifest.rs | 89 --- cores/mizan-rust/tests/ssr_bridge.rs | 105 ---- cores/mizan-rust/tests/token_pin.rs | 153 ----- frontends/mizan-base/src/index.ts | 41 +- protocol/mizan-codegen/src/emit/channels.rs | 1 - protocol/mizan-codegen/src/emit/python.rs | 3 - protocol/mizan-codegen/src/emit/rust.rs | 3 - protocol/mizan-codegen/src/emit/stage1.rs | 1 - protocol/mizan-codegen/src/ir.rs | 24 - .../mizan-codegen/tests/upload_codegen.rs | 79 --- tests/afi/fixture.py | 6 +- tests/afi/manifest.py | 167 ------ tests/afi/parity_table.py | 141 ----- tests/afi/probes.py | 388 ------------ tests/afi/rust_app/Cargo.lock | 303 ---------- tests/afi/test_capability_parity.py | 118 ---- 126 files changed, 1711 insertions(+), 13265 deletions(-) create mode 100644 CLAUDE.md rename {cores/mizan-python/src/mizan_core => backends/mizan-django/src/mizan}/ssr/bridge.py (92%) delete mode 100644 backends/mizan-django/src/mizan/tests/test_upload.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/auth.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/config.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/manifest.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/shapes.py delete mode 100644 backends/mizan-fastapi/src/mizan_fastapi/ssr.py delete mode 100644 backends/mizan-fastapi/tests/test_edge_manifest.py delete mode 100644 backends/mizan-fastapi/tests/test_forms.py delete mode 100644 backends/mizan-fastapi/tests/test_parity.py delete mode 100644 backends/mizan-fastapi/tests/test_shapes.py delete mode 100644 backends/mizan-fastapi/tests/test_ssr.py delete mode 100644 backends/mizan-fastapi/tests/test_upload.py delete mode 100644 backends/mizan-fastapi/tests/test_websocket.py delete mode 100644 backends/mizan-rust-axum/src/forms.rs delete mode 100644 backends/mizan-rust-axum/src/ssr.rs delete mode 100644 backends/mizan-rust-axum/src/state.rs delete mode 100644 backends/mizan-rust-axum/src/ws.rs delete mode 100644 backends/mizan-rust-axum/tests/behavior.rs delete mode 100644 backends/mizan-tauri/src/ssr.rs delete mode 100644 backends/mizan-tauri/tests/behavior.rs delete mode 100644 backends/mizan-ts/src/forms.ts delete mode 100644 backends/mizan-ts/src/identity.ts delete mode 100644 backends/mizan-ts/src/ir/build.ts delete mode 100644 backends/mizan-ts/src/ir/index.ts delete mode 100644 backends/mizan-ts/src/ir/types.ts delete mode 100644 backends/mizan-ts/src/session.ts delete mode 100644 backends/mizan-ts/src/shapes.ts delete mode 100644 backends/mizan-ts/src/ssr.ts delete mode 100644 backends/mizan-ts/src/token.ts delete mode 100644 backends/mizan-ts/src/upload.ts delete mode 100644 backends/mizan-ts/src/websocket.ts delete mode 100644 backends/mizan-ts/tests/auth.test.ts delete mode 100644 backends/mizan-ts/tests/fixtures/Hello.tsx delete mode 100644 backends/mizan-ts/tests/fixtures/stub-worker.mjs delete mode 100644 backends/mizan-ts/tests/ir-fixture.ts delete mode 100644 backends/mizan-ts/tests/ir.test.ts delete mode 100644 backends/mizan-ts/tests/shapes-forms.test.ts delete mode 100644 backends/mizan-ts/tests/ssr.test.ts delete mode 100644 backends/mizan-ts/tests/token.test.ts delete mode 100644 backends/mizan-ts/tests/transport.test.ts delete mode 100644 backends/mizan-ts/tests/upload.test.ts delete mode 100644 cores/mizan-python/src/mizan_core/auth/__init__.py delete mode 100644 cores/mizan-python/src/mizan_core/auth/authenticate.py delete mode 100644 cores/mizan-python/src/mizan_core/auth/jwt.py delete mode 100644 cores/mizan-python/src/mizan_core/authguard.py delete mode 100644 cores/mizan-python/src/mizan_core/dispatch.py delete mode 100644 cores/mizan-python/src/mizan_core/errors.py delete mode 100644 cores/mizan-python/src/mizan_core/identity.py delete mode 100644 cores/mizan-python/src/mizan_core/invalidation.py delete mode 100644 cores/mizan-python/src/mizan_core/manifest.py delete mode 100644 cores/mizan-python/src/mizan_core/ssr/__init__.py delete mode 100644 cores/mizan-python/src/mizan_core/upload.py delete mode 100644 cores/mizan-python/tests/test_dispatch_core.py delete mode 100644 cores/mizan-rust/src/auth.rs delete mode 100644 cores/mizan-rust/src/cache.rs delete mode 100644 cores/mizan-rust/src/manifest.rs delete mode 100644 cores/mizan-rust/src/shapes.rs delete mode 100644 cores/mizan-rust/src/ssr.rs delete mode 100644 cores/mizan-rust/src/upload.rs delete mode 100644 cores/mizan-rust/tests/cache_keys_pin.rs delete mode 100644 cores/mizan-rust/tests/invalidate_header_pin.rs delete mode 100644 cores/mizan-rust/tests/shapes_manifest.rs delete mode 100644 cores/mizan-rust/tests/ssr_bridge.rs delete mode 100644 cores/mizan-rust/tests/token_pin.rs delete mode 100644 protocol/mizan-codegen/tests/upload_codegen.rs delete mode 100644 tests/afi/manifest.py delete mode 100644 tests/afi/parity_table.py delete mode 100644 tests/afi/probes.py delete mode 100644 tests/afi/test_capability_parity.py diff --git a/.gitea/workflows/publish-django.yaml b/.gitea/workflows/publish-django.yaml index d38c83e..3d98e0a 100644 --- a/.gitea/workflows/publish-django.yaml +++ b/.gitea/workflows/publish-django.yaml @@ -1,4 +1,4 @@ -name: Publish Django package to Gitea registry +name: Publish Django package to PyPI on: push: diff --git a/.gitea/workflows/publish-react.yaml b/.gitea/workflows/publish-react.yaml index 6b293a1..879d95e 100644 --- a/.gitea/workflows/publish-react.yaml +++ b/.gitea/workflows/publish-react.yaml @@ -1,4 +1,4 @@ -name: Publish React package to Gitea registry +name: Publish React package to npm on: push: diff --git a/.gitignore b/.gitignore index dcbbf8d..0a8911b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,3 @@ examples/django-react-site/harness/test-results/ .env.* *.pem *.key - -# Agent worktrees (transient scratch — never tracked) -.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..228255c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,472 @@ +# 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//?param1=val1¶m2=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, 'ProfilePage', props)` 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: +``` +→ {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {"userId": 5}}} +← {"id": 1, "html": "
...
"} +``` + +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//') +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//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', + 'OPTIONS': { + 'worker_path': 'frontend/ssr-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, 'ProfilePage', {'profile': profile}) +``` + +`render()` calls `MizanTemplates.get_template('ProfilePage')` which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC to the Bun worker. + +### SSR Bridge (bridge.py) + +- Spawns `bun run ` 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 +- Component registry: `registerComponent('ProfilePage', ProfilePage)` +- Calls `renderToString(createElement(Component, props))` +- 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//"} + ], + "endpoints": ["/api/mizan/ctx/user/"], + "params": ["user_id"], + "user_scoped": true, + "render_strategy": "dynamic_cached", + "page_routes": ["/profile//"] + } + }, + "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//", 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. diff --git a/ISSUES.md b/ISSUES.md index 60c76a5..2571696 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -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. - [ ] **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. -- [ ] **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`), 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. -- [ ] **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`. ## Resolved this pass diff --git a/Makefile b/Makefile index 7578fe0..7531560 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test test-core test-django test-fastapi test-react test-afi parity-table parity-check test-integration docker-up docker-down clean +.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean CORE = cores/mizan-python DJANGO = backends/mizan-django @@ -30,24 +30,11 @@ test-fastapi: test-react: cd $(REACT) && npm test -# AFI conformance — two gates, substrate-level, not e2e: -# test_codegen_parity.py — Django/FastAPI/Rust emit byte-identical KDL IR. -# test_capability_parity.py — every (capability, applicable adapter) pair is -# probed for its wiring. RED on every unwired gap -# by design: that board is the owed work, itemized. +# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent +# schemas for the same @client fixture. Substrate-level gate, not e2e. test-afi: cd $(AFI) && uv run pytest -# Regenerate the README parity table from the live conformance probes. The table -# is generated output — never hand-edited. -parity-table: - cd $(AFI) && uv run python parity_table.py --write - -# CI gate: the committed README parity table matches what the probes report. -# Fails on any hand-edit, the same forcing function as the codegen byte-parity. -parity-check: - cd $(AFI) && uv run python parity_table.py --check - # ─── Integration Tests ────────────────────────────────────────────────────── test-integration: docker-up diff --git a/README.md b/README.md index 088813a..a95d543 100644 --- a/README.md +++ b/README.md @@ -33,86 +33,86 @@ reference implementation; per-adapter support is inventoried below. ## Backend adapters -Every adapter implements the same AFI wire protocol. The matrix below is **generated** -from the conformance probes in [`tests/afi/`](tests/afi/) by `make parity-table` — it is -output, not prose. A cell goes `✅` only when that adapter wires the capability into its -own dispatch surface; it cannot be set to "supported" or "Django-only" by editing this -file (a hand-edit fails `python tests/afi/parity_table.py --check` in CI, the same -forcing function the codegen byte-parity tests use). +Every adapter implements the same AFI wire protocol. The matrix below inventories +support per adapter, grouped to separate protocol guarantees from Django-specific +features (forms, ORM projection, auth providers, SSR). A cell counts as supported only +when that adapter wires the capability into its own dispatch surface, not merely that a +shared core primitive exists. -Every capability in the matrix is **AFI-common** — each adapter owes a binding, and a -`❌` is a gap on the owed-work board, never a "this framework doesn't do that." The line -between AFI-common and genuinely backend-bound lives in -[`tests/afi/manifest.py`](tests/afi/manifest.py): what sits *outside* the matrix by -design is the `allauth` integration (a Django-ecosystem package) and the per-stack -*bindings* of common capabilities (`django-readers` is Django's Shapes binding; Django -Forms is Django's Forms binding) — the capability is common; the binding is not. - - -Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · — not applicable to this adapter's transport - -Every capability below is **AFI-common**: each adapter owes a binding, and a ❌ is a gap on the owed-work board (`tests/afi/`), never a category. Backend-specific *bindings* of common capabilities (django-readers for Shapes, Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are out of this matrix by design — see `tests/afi/manifest.py` for the line. +Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport ### Protocol core +The surface every Mizan adapter implements. + | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ | ✅ | +| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ | | Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ | | Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ | -| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ✅ | — | ✅ | | Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ | | Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ | -| Codegen IR export (KDL) | ✅ | ✅ | ✅ | ✅ | ✅ | -| File uploads (`Upload` type) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ | ### Edge, cache & enforcement -| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | -|---|:---:|:---:|:---:|:---:|:---:| -| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ✅ | ✅ | ✅ | -| Origin-side HMAC cache | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edge manifest export | ✅ | ✅ | ✅ | — | ✅ | -| PSR (`render_strategy` in manifest) | ✅ | ✅ | ✅ | — | ✅ | -| Session / CSRF init endpoint | ✅ | ✅ | ✅ | — | ✅ | - -### Extension points +Protocol transports and guarantees co-equal with the body channel in the spec. | Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | |---|:---:|:---:|:---:|:---:|:---:| -| WebSocket transport (`websocket=` declared) | ✅ | ✅ | ✅ | ✅ | ✅ | -| SSR bridge (subprocess renderer) | ✅ | ✅ | ✅ | ✅ | ✅ | -| JWT auth (access / refresh) | ✅ | ✅ | ✅ | ✅ | ✅ | -| MWT (edge identity token) | ✅ | ✅ | ✅ | — | ✅ | -| Typed query projection (Shapes) | ✅ | ✅ | ✅ | ✅ | ✅ | -| Forms (schema / validate / submit) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ | +| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ | +| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ | +| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ | +| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ | +| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ | + +> **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. + +### Stack extensions (Django) + +Django ecosystem features Mizan wraps. Other adapters provide these only where the +target stack calls for them. + +| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript | +|---|:---:|:---:|:---:|:---:|:---:| +| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ | +| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ | +| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ | +| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — | +| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ | +| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ | +| SSR bridge | ✅ | ❌ | ❌ | — | ❌ | +| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ | **Notes** -- **Invalidation — `X-Mizan-Invalidate` header** — The header channel is co-equal with the body channel in the spec. IPC transports carry invalidation in the response envelope instead. -- **Edge manifest export** — The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge. -- **MWT (edge identity token)** — MWT exists to key an edge cache; without an edge there is nothing to key. -- **Typed query projection (Shapes)** — The capability is AFI-common; the binding is per-ORM (django-readers on Django, the project's ORM elsewhere). -- **Forms (schema / validate / submit)** — The capability is AFI-common; the binding is per-framework (Django Forms on Django, Pydantic-or-equivalent elsewhere). - +1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP. + Invalidation rides in the JSON response body; there is no header channel. +2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum + WebSocket handler yet. +3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint. +4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every + adapter carries typed input/output through the KDL IR; the projection primitive + itself is Django-only. +5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not + enforce them. Rust/Axum has no enforcement either. +6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme + registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin) + rather than fetching over HTTP. +7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire + parity; CSRF is Django-only. +8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a + codegen source — it demonstrates the cache + invalidation protocol is + language-agnostic. ## Conformance -Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/), at -two layers: - -- **IR-shape parity** (`test_codegen_parity.py`) — Django, FastAPI, and the Rust adapter - emit byte-identical KDL for the same registered fixture. The IR is the contract; the - language that wrote the backend is irrelevant to the codegen-facing artifact. -- **Capability parity** (`test_capability_parity.py`) — every `(capability, applicable - adapter)` pair declared in `manifest.py` is probed for its actual wiring (`probes.py`). - A gap is a **red test that names the owed binding**, not a footnote. The suite is - intentionally red wherever a capability is unwired: that redness is the owed-work - board, itemized and loud, and a gap turns green by being *wired*, never by being - *described*. This is the per-capability gate the roadmap previously deferred. - -The generated table above is rendered from the capability layer, and the `--check` -diff keeps the README honest to the probes on every CI run. +Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It +currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and +the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability +runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned. ## License diff --git a/ROADMAP.md b/ROADMAP.md index dd79e79..8de5c2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,7 +34,7 @@ - [ ] **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. - [ ] **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`). --- diff --git a/backends/mizan-django/src/mizan/__init__.py b/backends/mizan-django/src/mizan/__init__.py index 06f1b8d..bda06c6 100644 --- a/backends/mizan-django/src/mizan/__init__.py +++ b/backends/mizan-django/src/mizan/__init__.py @@ -89,7 +89,6 @@ from . import setup from .channels import ReactChannel from .channels import register as register_channel 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 # imports contenttypes, which can't happen during apps.populate() @@ -165,10 +164,6 @@ __all__ = [ "GlobalContext", "ServerFunction", "ComposedContext", - # File uploads - "Upload", - "File", - "UploadedFile", # Setup "mizan_clients", "mizan_module", diff --git a/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md b/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md index ad0a94d..0f0dcaa 100644 --- a/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md +++ b/backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md @@ -1,12 +1,16 @@ # Cache Module — Known Issues -Open issues against the current cache implementation. The cache uses -HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key; -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. +Open issues against the current cache implementation. Resolved items are +removed once their fix lands. ## 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 Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize` 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 +### 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 Concurrent cold misses on the same key all execute and write. No 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. diff --git a/backends/mizan-django/src/mizan/client/executor.py b/backends/mizan-django/src/mizan/client/executor.py index b528ed9..b3c6a52 100644 --- a/backends/mizan-django/src/mizan/client/executor.py +++ b/backends/mizan-django/src/mizan/client/executor.py @@ -29,10 +29,6 @@ from pydantic import BaseModel, ValidationError 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.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 if TYPE_CHECKING: @@ -116,14 +112,53 @@ def _check_auth_requirement( 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. """ - # Evaluation lives in the shared core (mizan_core.authguard); the callable - # path receives the native Django request. Core raises; we render to the - # Django-shim FunctionError shape the executor expects. - try: - _core_enforce_auth(getattr(request, "user", None), auth_requirement, request) + if auth_requirement is None: return None - except _CoreMizanError as e: - return FunctionError(code=ErrorCode(e.code.value), message=e.message) + + user = request.user + + # 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 _cache_log = logging.getLogger("mizan.cache") @@ -162,6 +197,51 @@ def _purge_cache_for_invalidation( _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( view_class: type | 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 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( @@ -199,12 +321,94 @@ def _resolve_merges( Mirrors _resolve_invalidation's tier-1 auto-scoping for params. 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: - """Format invalidation targets as the X-Mizan-Invalidate header value (shared core).""" - return _core_inval.format_invalidate_header(invalidate) +def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None: + """Find the unique function-name slot in context whose return type matches mutation's output.""" + 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( @@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse: is_multipart = content_type.startswith("multipart/form-data") if is_multipart: - # Multipart carries two shapes: a form submission (Django Form path) or - # an Upload-typed RPC. `fn` selects the function; its kind routes here. + # Multipart form data - used by form submit functions fn_name = request.POST.get("fn") if not fn_name: return FunctionError( @@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse: message="Missing 'fn' field", ).to_response() - fn_class = get_function(fn_name) - is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False + # Get form data (excluding 'fn') + input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"} - 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"} - request._mizan_form_data = input_data - 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() + # Attach parsed form data and files to request for form functions + request._mizan_form_data = input_data + request._mizan_form_files = request.FILES else: # JSON body - standard RPC diff --git a/backends/mizan-django/src/mizan/export/__init__.py b/backends/mizan-django/src/mizan/export/__init__.py index 3829f9d..230b7d0 100644 --- a/backends/mizan-django/src/mizan/export/__init__.py +++ b/backends/mizan-django/src/mizan/export/__init__.py @@ -1,11 +1,10 @@ """ -Mizan Edge Manifest Generator (Django adapter surface). +Mizan Edge Manifest Generator. -The manifest derivation is AFI-common and lives in `mizan_core.manifest`; -Django exposes it through `python manage.py export_edge_manifest` and this -re-export. The manifest maps contexts to URL patterns and params, consumed by -Mizan Edge at deploy time for CDN cache invalidation. It is independent of the -Mizan IR: the IR drives codegen, the manifest drives CDN purging. +Generates the Edge manifest — a static JSON mapping contexts to URL +patterns and params, consumed by Mizan Edge at deploy time for CDN +cache invalidation. Independent from the Mizan IR; the IR drives +codegen, the manifest drives CDN purging. Usage: from mizan.export import generate_edge_manifest, generate_edge_manifest_json @@ -13,10 +12,145 @@ Usage: from __future__ import annotations -from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json +import json +import re +from typing import Any + +from mizan_core.registry import get_context_groups, get_registry __all__ = [ "generate_edge_manifest", "generate_edge_manifest_json", ] + + +def generate_edge_manifest( + base_url: str = "/api/mizan", + view_urls: dict[str, list[str]] | None = None, +) -> dict[str, Any]: + """ + Generate the Edge manifest — a static JSON mapping contexts to URL + patterns and params for CDN cache purging. + + The manifest is consumed by Mizan Edge at deploy time. When Edge + receives X-Mizan-Invalidate: user;user_id=5, it: + 1. Looks up 'user' in the manifest + 2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/ + 3. Purges the resolved URLs + the context API endpoint + + Args: + base_url: The Mizan API mount point (default: /api/mizan) + view_urls: Optional mapping of context names to URL patterns for + view-path functions. These are URLs that Edge should + also purge when a context is invalidated. + + Returns: + Manifest dict suitable for JSON serialization. + """ + _USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"} + + groups = get_context_groups() + registry = get_registry() + all_functions = registry.get("functions", {}) + + manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}} + + for ctx_name, fn_names in sorted(groups.items()): + param_names: set[str] = set() + functions_meta: list[dict[str, Any]] = [] + page_routes: list[str] = [] + + for fn_name in fn_names: + fn_cls = all_functions.get(fn_name) + if fn_cls is None: + continue + + input_cls = getattr(fn_cls, "Input", None) + if input_cls is not None and hasattr(input_cls, "model_fields"): + for param_name in input_cls.model_fields: + param_names.add(param_name) + + meta = getattr(fn_cls, "_meta", {}) + route = meta.get("route") + view_path = meta.get("view_path") + + fn_entry: dict[str, Any] = { + "name": fn_name, + "path": "view" if view_path else "rpc", + } + if route: + fn_entry["route"] = route + fn_entry["methods"] = meta.get("methods", ["GET"]) + page_routes.append(route) + if meta.get("rev"): + fn_entry["rev"] = meta["rev"] + if meta.get("cache") is not None and meta.get("cache") is not True: + fn_entry["cache"] = meta["cache"] + functions_meta.append(fn_entry) + + sorted_params = sorted(param_names) + user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names) + + ctx_entry: dict[str, Any] = { + "functions": functions_meta, + "endpoints": [f"{base_url}/ctx/{ctx_name}/"], + "params": sorted_params, + "user_scoped": user_scoped, + "render_strategy": "dynamic_cached" if user_scoped else "psr", + } + + if page_routes: + ctx_entry["page_routes"] = page_routes + if view_urls and ctx_name in view_urls: + ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name]) + + manifest["contexts"][ctx_name] = ctx_entry + + for fn_name, fn_cls in sorted(all_functions.items()): + meta = getattr(fn_cls, "_meta", {}) + if not meta.get("affects"): + continue + + affected_contexts = list({a["name"] for a in meta["affects"]}) + mutation: dict[str, Any] = {"affects": affected_contexts} + + # Auto-scoped params — function params that match context params + input_cls = getattr(fn_cls, "Input", None) + if input_cls is not None and hasattr(input_cls, "model_fields"): + fn_params = set(input_cls.model_fields.keys()) + auto_scoped: list[str] = [] + for ctx_name in affected_contexts: + ctx_param_names: set[str] = set() + ctx_fns = groups.get(ctx_name, []) + for ctx_fn_name in ctx_fns: + ctx_fn_cls = all_functions.get(ctx_fn_name) + if ctx_fn_cls is None: + continue + ctx_input = getattr(ctx_fn_cls, "Input", None) + if ctx_input is not None and hasattr(ctx_input, "model_fields"): + ctx_param_names.update(ctx_input.model_fields.keys()) + for p in fn_params: + if p in ctx_param_names and p not in auto_scoped: + auto_scoped.append(p) + if auto_scoped: + mutation["auto_scoped_params"] = sorted(auto_scoped) + + if meta.get("private"): + mutation["private"] = True + if meta.get("route"): + mutation["route"] = meta["route"] + mutation["methods"] = meta.get("methods", ["POST"]) + + manifest["mutations"][fn_name] = mutation + + return manifest + + +def generate_edge_manifest_json( + base_url: str = "/api/mizan", + view_urls: dict[str, list[str]] | None = None, + indent: int = 2, +) -> str: + """JSON-serialize the Edge manifest.""" + return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent) diff --git a/backends/mizan-django/src/mizan/jwt/tokens.py b/backends/mizan-django/src/mizan/jwt/tokens.py index c0ff6e6..b973e17 100644 --- a/backends/mizan-django/src/mizan/jwt/tokens.py +++ b/backends/mizan-django/src/mizan/jwt/tokens.py @@ -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`) -lives in the core; this module binds it to Django settings and keeps the -session-revocation check (`validate_session`), which is Django-session-specific. +Uses PyJWT directly - no allauth dependency. +Tokens are tied to Django sessions for immediate revocation on logout. """ -from __future__ import annotations +import time +from typing import NamedTuple -from mizan_core.auth import jwt as _core_jwt -from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload +import jwt +from django.contrib.sessions.backends.base import SessionBase from .settings import get_settings -__all__ = [ - "TokenPair", - "TokenPayload", - "JWTUser", - "create_access_token", - "create_refresh_token", - "create_token_pair", - "decode_token", - "validate_session", - "refresh_tokens", -] + +class TokenPair(NamedTuple): + """Access and refresh token pair.""" + access_token: str + refresh_token: str + expires_in: int -def _config() -> JWTConfig: - s = get_settings() - return JWTConfig( - private_key=s.private_key, - public_key=s.public_key, - algorithm=s.algorithm, - access_token_expires_in=s.access_token_expires_in, - refresh_token_expires_in=s.refresh_token_expires_in, +class TokenPayload(NamedTuple): + """Decoded token payload.""" + user_id: int | str + session_key: str + token_type: str + is_staff: bool + is_superuser: bool + 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: - return _core_jwt.create_access_token(user_id, session_key, _config(), - is_staff=is_staff, is_superuser=is_superuser) +def create_refresh_token( + user_id: int | str, + 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: - return _core_jwt.create_refresh_token(user_id, session_key, _config(), - is_staff=is_staff, is_superuser=is_superuser) +def create_token_pair( + user_id: int | str, + 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: - return _core_jwt.create_token_pair(user_id, session_key, _config(), - is_staff=is_staff, is_superuser=is_superuser) +def decode_token(token: str, expected_type: str = None) -> TokenPayload | None: + """ + 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: - return _core_jwt.decode_token(token, _config(), expected_type=expected_type) + try: + 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: - """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 - Django-session-bound piece; the core's `refresh_tokens` takes it as an - injected `session_validator`. + This is the key to immediate logout revocation - if the session + is destroyed, tokens tied to it become invalid. """ from importlib import import_module 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 + # Use the configured 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) 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, + ) diff --git a/backends/mizan-django/src/mizan/ssr/backend.py b/backends/mizan-django/src/mizan/ssr/backend.py index 29bad1a..f9f3ac3 100644 --- a/backends/mizan-django/src/mizan/ssr/backend.py +++ b/backends/mizan-django/src/mizan/ssr/backend.py @@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist from django.template.backends.base import BaseEngine from django.utils.safestring import mark_safe -from mizan_core.ssr import SSRBridge +from .bridge import SSRBridge class MizanTemplate: diff --git a/cores/mizan-python/src/mizan_core/ssr/bridge.py b/backends/mizan-django/src/mizan/ssr/bridge.py similarity index 92% rename from cores/mizan-python/src/mizan_core/ssr/bridge.py rename to backends/mizan-django/src/mizan/ssr/bridge.py index 745759b..78d1084 100644 --- a/cores/mizan-python/src/mizan_core/ssr/bridge.py +++ b/backends/mizan-django/src/mizan/ssr/bridge.py @@ -1,10 +1,5 @@ """ -SSR Bridge — manages a persistent Bun subprocess for React rendering. - -Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker, -speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it -over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR -render path — so the subprocess lifecycle and wire protocol are authored once. +SSR Bridge — Manages a persistent Bun subprocess for React rendering. Protocol: newline-delimited JSON-RPC over stdin/stdout. @@ -38,7 +33,7 @@ class SSRBridge: """ Manages a persistent Bun subprocess for server-side rendering. - Thread-safe. Multiple worker threads can call render() concurrently. + Thread-safe. Multiple Django workers can call render() concurrently. Request-response matching via message IDs. """ diff --git a/backends/mizan-django/src/mizan/tests/test_auth.py b/backends/mizan-django/src/mizan/tests/test_auth.py index 58c18bb..661960c 100644 --- a/backends/mizan-django/src/mizan/tests/test_auth.py +++ b/backends/mizan-django/src/mizan/tests/test_auth.py @@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase): def test_jwt_expired_with_session(self): """Expired JWT with valid session → Reject (do NOT fall back).""" - # Create token with past expiration by mocking time (minting lives in the core now) - with patch("mizan_core.auth.jwt.time.time", return_value=0): + # Create token with past expiration by mocking time + with patch("mizan.jwt.tokens.time.time", return_value=0): tokens = create_token_pair( self.user.pk, self.session_key, diff --git a/backends/mizan-django/src/mizan/tests/test_upload.py b/backends/mizan-django/src/mizan/tests/test_upload.py deleted file mode 100644 index 41dd414..0000000 --- a/backends/mizan-django/src/mizan/tests/test_upload.py +++ /dev/null @@ -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()) diff --git a/backends/mizan-fastapi/pyproject.toml b/backends/mizan-fastapi/pyproject.toml index fa5894a..029d485 100644 --- a/backends/mizan-fastapi/pyproject.toml +++ b/backends/mizan-fastapi/pyproject.toml @@ -8,13 +8,8 @@ dependencies = [ "mizan-core", "fastapi>=0.110", "pydantic>=2.0", - "python-multipart>=0.0.9", - "sqlalchemy>=2.0", ] -[project.scripts] -mizan-fastapi-edge-manifest = "mizan_fastapi.manifest:main" - [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py index e28c5a7..13c2bc4 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/__init__.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/__init__.py @@ -2,23 +2,9 @@ mizan-fastapi — FastAPI backend adapter for the Mizan protocol. HTTP RPC dispatch and context bundling on top of mizan-core's function -registry, sharing the auth / invalidation / cache / upload core with the -Django adapter. - -The full AFI-common surface is wired here over FastAPI-native primitives, -each riding the shared core: - -- WebSocket RPC — `router`'s `/ws/` route dispatches `@client(websocket=True)` - functions through the same `mizan_core.dispatch` as `POST /call/`. -- SSR — `SSRRenderer` (`mizan_fastapi.ssr`) renders React via the shared - `mizan_core.ssr.SSRBridge` Bun subprocess. -- Edge manifest / PSR — `edge_manifest` (and the `mizan-fastapi-edge-manifest` - console entry) emit the manifest derived in `mizan_core.manifest`, including - each context's `render_strategy`. -- Shapes — `mizan_fastapi.shapes.Shape` is the typed query projection bound to - SQLAlchemy (same declaration surface as the Django `django-readers` binding). -- Forms — `mizan_fastapi.forms.mizanForm` exposes schema / validate / submit - role functions over Pydantic. +registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI +projects use native equivalents (WebSocket, Pydantic, ORM-of-choice, +SSR frameworks). Usage: from fastapi import FastAPI @@ -48,54 +34,14 @@ from .executor import ( compute_invalidation, execute_function, ) -# Register the FastAPI/Starlette response base so view-path detection works in -# mizan_core.client.function (a @client function returning a Response is a -# view-path function — header-only invalidation, "view" in the edge manifest). -# Must run before any @client-decorated code is evaluated. -from starlette.responses import Response as _Response -from mizan_core.client.function import set_framework_response_base as _set_response_base -_set_response_base(_Response) - -from . import shapes, forms from .router import router, mizan_exception_handler, mizan_validation_handler -from .auth import MizanAuthMiddleware, mizan_auth -from .config import MizanConfig, from_env -from .manifest import edge_manifest, generate_edge_manifest, render_strategies -from .ssr import SSRRenderer -from mizan_core.upload import File, Upload, UploadedFile - -# Shapes (SQLAlchemy query projection) and Forms (Pydantic schema/validate/submit) -# are submodule bindings; expose their public primitives at the package root. -Shape = shapes.Shape -Diff = shapes.Diff -NestedDiff = shapes.NestedDiff -mizanForm = forms.mizanForm -FormConfig = forms.FormConfig __all__ = [ - "Upload", - "File", - "UploadedFile", - "mizan_auth", - "MizanAuthMiddleware", - "MizanConfig", - "from_env", "router", "mizan_exception_handler", "mizan_validation_handler", "execute_function", "compute_invalidation", - "edge_manifest", - "generate_edge_manifest", - "render_strategies", - "SSRRenderer", - "shapes", - "forms", - "Shape", - "Diff", - "NestedDiff", - "mizanForm", - "FormConfig", "ErrorCode", "MizanError", "NotFound", diff --git a/backends/mizan-fastapi/src/mizan_fastapi/auth.py b/backends/mizan-fastapi/src/mizan_fastapi/auth.py deleted file mode 100644 index cb76068..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/auth.py +++ /dev/null @@ -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) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/config.py b/backends/mizan-fastapi/src/mizan_fastapi/config.py deleted file mode 100644 index 0641da3..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/config.py +++ /dev/null @@ -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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/executor.py b/backends/mizan-fastapi/src/mizan_fastapi/executor.py index 6d7a395..64bdac1 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/executor.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/executor.py @@ -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) -lives in `mizan_core`; this module re-exports the canonical error taxonomy and -keeps backward-compatible helpers. The router drives `dispatch_call` / -`dispatch_context` directly to get invalidation + origin cache. +Errors raise typed exceptions (MizanError subclasses). Wire those to JSON +responses by registering `mizan_exception_handler` on the FastAPI app, or +let them propagate to your own handler. """ from __future__ import annotations +from enum import Enum from typing import Any -from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call -from mizan_core.errors import ( - BadRequest, - ErrorCode, - Forbidden, - InternalError, - MizanError, - NotFound, - NotImplementedYet, - Unauthorized, - ValidationFailed, -) -from mizan_core.invalidation import resolve_invalidation, resolve_merges +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ValidationError -__all__ = [ - "ErrorCode", - "MizanError", - "NotFound", - "BadRequest", - "ValidationFailed", - "Unauthorized", - "Forbidden", - "NotImplementedYet", - "InternalError", - "compute_invalidation", - "compute_merges", - "execute_function", -] +from mizan_core.registry import get_context_groups, get_function +from mizan_core.type_utils import types_match_for_merge -_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]: - """`@client(affects=...)` → invalidation list (empty when none). Shared core.""" - return resolve_invalidation(view_class, input_data) or [] + """Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params.""" + 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]]: - """`@client(merge=...)` → merge list (empty when none). Shared core.""" - return resolve_merges(view_class, input_data, result) or [] + """Build the `merge` list from @client(merge=...) metadata. - -async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any: - """Dispatch a function and return its serialized result (auth enforced via core). - - Backward-compat entry point; the router uses `dispatch_call` directly to also - capture invalidation/merge and run the origin cache. + Each entry is `{context, slot, value, params?}` where `slot` names the + function inside the context bundle the value lands in. The slot is + resolved server-side via `types_match_for_merge` so the kernel does + no shape inference — the server has the schema, type-checked routing + lives here. Entries whose slot can't be uniquely resolved are dropped + with a warning; the consumer falls back to refetch via `affects`. """ - identity = getattr(getattr(request, "state", None), "user", None) - res = await dispatch_call( - DispatchRequest(identity=identity, args=input_data, native_request=request), - fn_name, - _NO_CACHE, - ) - return res.data + targets = getattr(view_class, "_meta", {}).get("merge") or [] + if not targets: + return [] + mutation_output = getattr(view_class, "Output", None) + out: list[dict[str, Any]] = [] + for ctx_name in targets: + 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 diff --git a/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py b/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py deleted file mode 100644 index e53cbf3..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/forms/__init__.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Forms — the Pydantic binding (schema / validate / submit roles). - -A Mizan form is exposed as three server functions — `{name}.schema`, -`{name}.validate`, `{name}.submit` — carrying `_meta["form_role"]` of -`"schema"`, `"validate"`, `"submit"`. That role contract is AFI-common and -identical to the Django adapter's (`mizan.forms`); only the *binding* differs: -Django wraps a `forms.Form`, this wraps a Pydantic `BaseModel`. - - from mizan_fastapi.forms import mizanForm, FormConfig - - class ContactForm(mizanForm): - mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send") - - name: str - email: EmailStr - message: str - - def on_submit_success(self, request) -> dict: - send_email(self.model_dump()) - return {"sent": True} - -Subclassing registers the three role functions automatically (parity with the -Django `mizanFormMixin.__init_subclass__` auto-registration): - - contact.schema → field definitions (FormSchema) - contact.validate → structured field errors (FormValidation) - contact.submit → validate, then on_submit_success / on_submit_failure -""" - -from __future__ import annotations - -from typing import Any, ClassVar, get_args, get_origin - -from pydantic import BaseModel, ValidationError, create_model - -from mizan_core.client.function import ServerFunction -from mizan_core.registry import get_all_functions, register - -from .schemas import ( - FieldError, - FieldErrorList, - FieldSchema, - FormMeta, - FormSchema, - FormSubmitFail, - FormSubmitPass, - FormValidation, -) - -__all__ = [ - "FormConfig", - "mizanForm", - "get_forms", - "FormSchema", - "FormValidation", - "FormSubmitPass", - "FormSubmitFail", -] - - -# Pydantic annotation → the (type, widget) the frontend renders. Mirrors the -# Django binding's `_django_field_to_python_type` intent: hand the client a real -# field type instead of a generic string. -_TYPE_WIDGET = { - bool: ("checkbox", "CheckboxInput"), - int: ("number", "NumberInput"), - float: ("number", "NumberInput"), - str: ("text", "TextInput"), -} - - -class FormConfig(BaseModel): - """Form metadata + frontend behavior (parity with `mizanFormMeta`).""" - - name: str - title: str | None = None - subtitle: str | None = None - submit_label: str = "Submit" - live_validation: bool = True - live_form_errors: bool = False - refetch_schema_on_validate: bool = False - - -def _unwrap_optional(annotation: Any) -> Any: - """`X | None` / `Optional[X]` → `X`; otherwise the annotation unchanged.""" - if get_origin(annotation) in (None,): - return annotation - args = [a for a in get_args(annotation) if a is not type(None)] - if len(args) == 1 and type(None) in get_args(annotation): - return args[0] - return annotation - - -def _field_type_widget(annotation: Any) -> tuple[str, str]: - base = _unwrap_optional(annotation) - return _TYPE_WIDGET.get(base, ("text", "TextInput")) - - -def _humanize(name: str) -> str: - return name.replace("_", " ").title() - - -def build_form_schema(form_cls: type["mizanForm"]) -> FormSchema: - """Derive a `FormSchema` from a Pydantic form's fields + config.""" - cfg = form_cls.mizan - fields: list[FieldSchema] = [] - for field_name, info in form_cls.model_fields.items(): - type_str, widget = _field_type_widget(info.annotation) - required = info.is_required() - initial = None if required else info.get_default(call_default_factory=False) - if initial is None and info.default is not None and info.default is not ...: - initial = info.default - meta = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {} - fields.append( - FieldSchema( - name=field_name, - label=str(info.title or _humanize(field_name)), - type=type_str, - widget=widget, - required=required, - disabled=bool(meta.get("disabled", False)), - help_text=str(info.description or ""), - initial=initial if initial is not ... else None, - max_length=getattr(info, "max_length", None), - min_length=getattr(info, "min_length", None), - choices=None, - ) - ) - return FormSchema( - name=cfg.name, - title=cfg.title or _humanize(form_cls.__name__.removesuffix("Form")), - subtitle=cfg.subtitle, - submit_label=cfg.submit_label, - fields=fields, - meta=FormMeta( - refetch_schema_on_validate=cfg.refetch_schema_on_validate, - live_validation=cfg.live_validation, - live_form_errors=cfg.live_form_errors, - ), - ) - - -def _validation_from_error(exc: ValidationError) -> FormValidation: - """Group a Pydantic `ValidationError` into the `FormValidation` wire shape.""" - by_field: dict[str, list[FieldError]] = {} - for err in exc.errors(): - loc = err.get("loc", ()) - field = str(loc[0]) if loc else "__all__" - by_field.setdefault(field, []).append( - FieldError(message=err.get("msg", "Invalid value"), code=err.get("type")) - ) - return FormValidation( - errors=[FieldErrorList(field=f, errors=errs) for f, errs in by_field.items()] - ) - - -def _validate(form_cls: type["mizanForm"], data: dict[str, Any]) -> tuple["mizanForm | None", FormValidation]: - """Validate `data`; return `(instance|None, validation)` — instance None on failure.""" - try: - instance = form_cls(**(data or {})) - return instance, FormValidation(errors=[]) - except ValidationError as exc: - return None, _validation_from_error(exc) - - -class mizanForm(BaseModel): - """Base for a Pydantic-backed Mizan form. - - Subclass with field annotations and a `mizan = FormConfig(...)`. Subclassing - auto-registers the schema/validate/submit role functions. Override - `on_submit_success` / `on_submit_failure` for submit-time behavior. - """ - - mizan: ClassVar[FormConfig] - - def on_submit_success(self, request: Any) -> dict | None: - """Handle a validated submission. Override; returns optional result data.""" - return None - - def on_submit_failure(self, request: Any, errors: FormValidation) -> None: - """Handle a failed submission (logging, etc.). Override.""" - return None - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - cfg = cls.__dict__.get("mizan") - if isinstance(cfg, FormConfig): - _register_form(cls) - - -def _register_form(form_cls: type[mizanForm]) -> None: - """Register `{name}.schema/.validate/.submit` for a Pydantic form class.""" - cfg = form_cls.mizan - name = cfg.name - pascal = "".join(w.capitalize() for w in name.replace(".", "_").replace("-", "_").split("_")) - - schema_input = create_model(f"{pascal}SchemaInput", data=(dict[str, Any], {})) - validate_input = create_model(f"{pascal}ValidateInput", data=(dict[str, Any], ...)) - submit_input = create_model(f"{pascal}SubmitInput", data=(dict[str, Any], ...)) - - class SchemaFunction(ServerFunction): - Input = schema_input - Output = FormSchema - _meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "schema"} - - def call(self, input) -> FormSchema: - return build_form_schema(form_cls) - - class ValidateFunction(ServerFunction): - Input = validate_input - Output = FormValidation - _meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "validate"} - - def call(self, input) -> FormValidation: - _, validation = _validate(form_cls, input.data) - return validation - - class SubmitFunction(ServerFunction): - Input = submit_input - Output = FormSubmitPass - _meta: ClassVar[dict] = {"form": True, "form_name": name, "form_role": "submit"} - - def call(self, input) -> FormSubmitPass | FormSubmitFail: - instance, validation = _validate(form_cls, input.data) - if instance is not None: - return FormSubmitPass(success=True, data=instance.on_submit_success(self.request)) - instance_for_failure = form_cls.model_construct(**(input.data or {})) - instance_for_failure.on_submit_failure(self.request, validation) - return FormSubmitFail(success=False, errors=validation) - - for fn, role in ((SchemaFunction, "schema"), (ValidateFunction, "validate"), (SubmitFunction, "submit")): - fn.__name__ = f"{name}_{role}" - fn.__qualname__ = fn.__name__ - register(fn, f"{name}.{role}") - - -def get_forms() -> dict[str, list]: - """Group registered form role functions by form name (parity helper).""" - forms: dict[str, list] = {} - for _, cls in get_all_functions().items(): - meta = getattr(cls, "_meta", {}) - if meta.get("form"): - forms.setdefault(meta.get("form_name"), []).append(cls) - return forms diff --git a/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py b/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py deleted file mode 100644 index 124869e..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/forms/schemas.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Form role output schemas — the wire shapes the schema/validate/submit roles emit. - -These mirror the Django adapter's `mizan.forms.schemas` field-for-field (FormMeta, -FieldSchema, FormSchema, FormValidation, FormSubmitPass/Fail) so the generated -client is identical regardless of which backend authored the form. The only -difference is the source: Django builds these from `forms.Field` introspection; -this builds them from Pydantic `FieldInfo`. -""" - -from __future__ import annotations - -from typing import Any, Optional - -from pydantic import BaseModel - - -class FormMeta(BaseModel): - """Frontend behavior flags (parity with the Django adapter).""" - - refetch_schema_on_validate: bool = False - live_validation: bool = True - live_form_errors: bool = False - - -class FieldChoice(BaseModel): - value: str - label: str - - -class FieldError(BaseModel): - message: str - code: Optional[str] = None - - -class FieldErrorList(BaseModel): - field: str - errors: list[FieldError] - - -class FieldSchema(BaseModel): - name: str - label: str - type: str - widget: str - required: bool - disabled: bool - help_text: str - initial: Any = None - max_length: Optional[int] = None - min_length: Optional[int] = None - choices: Optional[list[FieldChoice]] = None - - -class FormSchema(BaseModel): - """Schema returned by the `.schema` role: form metadata + field definitions.""" - - name: str - title: str - subtitle: Optional[str] = None - submit_label: str - fields: list[FieldSchema] - meta: FormMeta = FormMeta() - - -class FormValidation(BaseModel): - errors: list[FieldErrorList] - - -class FormSubmitPass(BaseModel): - success: bool - data: Optional[dict] = None - - -class FormSubmitFail(BaseModel): - success: bool - errors: FormValidation diff --git a/backends/mizan-fastapi/src/mizan_fastapi/manifest.py b/backends/mizan-fastapi/src/mizan_fastapi/manifest.py deleted file mode 100644 index c439b7d..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/manifest.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Edge manifest — FastAPI adapter surface. - -The manifest derivation is AFI-common (`mizan_core.manifest.generate_edge_manifest`); -this module exposes it over FastAPI's surface as a callable and a console entry -(`mizan-fastapi-edge-manifest`), mirroring Django's `export_edge_manifest` -management command. - -The `render_strategy` field each context carries — `"psr"` when the context has -no user-scoped param, `"dynamic_cached"` when it does — is the PSR signal Edge -reads to decide between one shared pre-rendered artifact and a per-user cached -one. It is derived in the core from the same registry metadata, so FastAPI and -Django emit byte-identical manifests for an identical registry. - -CLI: - mizan-fastapi-edge-manifest myproject.app - mizan-fastapi-edge-manifest myproject.app:app --base-url /api/mizan -o edge.json - -The positional argument is an import target (``module`` or ``module:attr``); it -is imported for its registration side effects (importing the module runs the -`@client` decorators and `register(...)` calls that populate the registry) -before the manifest is derived. -""" - -from __future__ import annotations - -import argparse -import importlib -import sys -from pathlib import Path -from typing import Any - -from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json - - -__all__ = ["edge_manifest", "generate_edge_manifest", "render_strategies", "main"] - - -def edge_manifest(base_url: str = "/api/mizan") -> dict[str, Any]: - """The Edge manifest for the current registry. - - Call after the app's `@client` functions are imported/registered. The - returned dict carries each context's ``render_strategy`` (PSR vs. - dynamic_cached) and the mutation→context invalidation routing. - """ - return generate_edge_manifest(base_url=base_url) - - -def render_strategies(base_url: str = "/api/mizan") -> dict[str, str]: - """Map each context to its ``render_strategy`` — ``"psr"`` or ``"dynamic_cached"``. - - PSR (Preemptive Static Rendering) is the per-context decision Edge needs: a - context with no user-scoped param renders one shared artifact (``psr``) that - is re-rendered on mutation; a user-scoped context renders per-user - (``dynamic_cached``). This surfaces that decision directly so a PSR driver can - enumerate which contexts to pre-render without re-deriving it. - """ - contexts = edge_manifest(base_url)["contexts"] - return {name: entry["render_strategy"] for name, entry in contexts.items()} - - -def _import_target(target: str) -> None: - """Import a ``module`` or ``module:attr`` target for its registration effects.""" - module_name = target.split(":", 1)[0] - importlib.import_module(module_name) - - -def main(argv: list[str] | None = None) -> int: - """Console entry: import the app target, emit the Edge manifest as JSON.""" - parser = argparse.ArgumentParser( - prog="mizan-fastapi-edge-manifest", - description="Export the Mizan Edge manifest for a FastAPI app.", - ) - parser.add_argument( - "app", - help="Import target whose @client functions to register " - "(e.g. 'myproject.app' or 'myproject.app:app').", - ) - parser.add_argument("--base-url", default="/api/mizan", help="Mizan API mount point.") - parser.add_argument("-o", "--output", default=None, help="Write to file instead of stdout.") - parser.add_argument("--indent", type=int, default=2, help="JSON indent (0 = compact).") - args = parser.parse_args(argv) - - sys.path.insert(0, "") - _import_target(args.app) - - indent = args.indent if args.indent > 0 else None - text = generate_edge_manifest_json(base_url=args.base_url, indent=indent) - - if args.output: - Path(args.output).write_text(text, encoding="utf-8") - else: - sys.stdout.write(text) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index db9155c..de8c828 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -14,22 +14,23 @@ FastAPI router exposing Mizan's HTTP endpoints: from __future__ import annotations -import json from typing import Any -from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Request from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, Response -from pydantic import BaseModel, Field, ValidationError -from starlette.datastructures import UploadFile +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field -from mizan_core.auth import INVALID, authenticate -from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context -from mizan_core.errors import BadRequest, ErrorCode, Forbidden, MizanError, NotFound, Unauthorized -from mizan_core.registry import get_function -from mizan_core.upload import UploadedFile, bind_uploads +from mizan_core.registry import get_context_groups, get_function -from .config import MizanConfig, get_config +from .executor import ( + ErrorCode, + MizanError, + NotFound, + compute_invalidation, + compute_merges, + execute_function, +) router = APIRouter() @@ -44,12 +45,11 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse: @router.get("/session/") async def session_init() -> JSONResponse: - """Session-init endpoint. AFI-common; wired here at parity with mizan-django. + """Session-init probe. Parity with mizan-django's session endpoint. - The endpoint itself is the AFI-common surface. The CSRF *token* is a Django - session mechanism with no FastAPI equivalent, so this returns a null token — - the difference is in the token's backing mechanism, not in whether the - endpoint is owed. The wire-parity harness uses it as its readiness probe. + CSRF is a Django-only concern at the protocol level; FastAPI surfaces a + null token so the response shape stays uniform across backends. The + wire-parity harness uses this endpoint as its readiness probe. """ return _no_store({"csrfToken": None}) @@ -59,197 +59,29 @@ class CallBody(BaseModel): 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/") -async def function_call(request: Request) -> JSONResponse: - """RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with - the `X-Mizan-Invalidate` header alongside the body.""" - cfg = get_config(request) - fn, args = await _parse_call(request) - res = await dispatch_call( - DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request), - fn, cfg.cache, - ) - payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []} - 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) +async def function_call(body: CallBody, request: Request) -> JSONResponse: + """RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`.""" + fn_class = get_function(body.fn) + result = await execute_function(request, body.fn, body.args) + invalidate = compute_invalidation(fn_class, body.args) + merges = compute_merges(fn_class, body.args, result) + payload: dict[str, Any] = {"result": result, "invalidate": invalidate} + if merges: + payload["merge"] = merges + return _no_store(payload) @router.get("/ctx/{context_name}/") -async def context_fetch(context_name: str, request: Request) -> Response: - """Bundled context fetch — origin-cached. `{function_name: result, ...}`.""" - cfg = get_config(request) - res = await dispatch_context( - DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params), - native_request=request), - context_name, cfg.cache, - ) - headers = {"Cache-Control": "no-store"} - if res.cache_status: - headers["X-Mizan-Cache"] = res.cache_status - return Response(content=res.body_bytes, media_type="application/json", headers=headers) +async def context_fetch(context_name: str, request: Request) -> JSONResponse: + """Bundled context fetch — `{function_name: result, ...}` for every function in the context.""" + fn_names = get_context_groups().get(context_name) + if not fn_names: + raise NotFound(f"Context '{context_name}' not found") - -# ─── WebSocket RPC transport ────────────────────────────────────────────────── - - -def _ws_identity(websocket: WebSocket, cfg: MizanConfig): - """Identity for a WebSocket RPC: a host-set `websocket.state.user`, else a - token decode from the handshake headers. A present-but-invalid token rejects. - - Mirrors the HTTP `_identity` path so a function's `auth=` guard enforces - identically over either transport. - """ - existing = getattr(getattr(websocket, "state", None), "user", None) - if existing is not None: - return existing - ident = authenticate(websocket.headers, cfg.auth) - if ident is INVALID: - raise Unauthorized("Invalid or expired token") - return ident - - -def _error_frame(request_id: Any, exc: MizanError) -> dict[str, Any]: - err: dict[str, Any] = {"code": exc.code.value, "message": exc.message} - if exc.details: - err["details"] = exc.details - return {"id": request_id, "ok": False, "error": err} - - -@router.websocket("/ws/") -async def websocket_rpc(websocket: WebSocket) -> None: - """WebSocket RPC transport for `@client(websocket=True)` functions. - - Frame protocol (parity with mizan-django's Channels consumer): - - → {"action": "rpc", "id": "", "fn": "", "args": {...}} - ← {"id": "", "ok": true, "data": , "invalidate": [...], "merge"?: [...]} - ← {"id": "", "ok": false, "error": {"code", "message", "details"?}} - - Each call runs through the SAME `mizan_core.dispatch.dispatch_call` as - `POST /call/`, so input validation, `auth=` enforcement, invalidation, merge, - and origin-cache purge are identical across transports. Only functions that - declared `websocket=True` are callable here; an HTTP-only function returns a - `FORBIDDEN` frame rather than executing. - """ - cfg = get_config(websocket) - await websocket.accept() - try: - identity = _ws_identity(websocket, cfg) - except Unauthorized as exc: - await websocket.send_json(_error_frame(None, exc)) - await websocket.close(code=1008) - return - - try: - while True: - content = await websocket.receive_json() - await _handle_ws_rpc(websocket, content, identity, cfg) - except WebSocketDisconnect: - return - - -async def _handle_ws_rpc(websocket: WebSocket, content: dict[str, Any], identity, cfg: MizanConfig) -> None: - """Dispatch one WS RPC frame through the shared dispatch core.""" - if content.get("action") != "rpc": - await websocket.send_json({"error": f"Unknown action: {content.get('action')}"}) - return - - request_id = content.get("id") - fn_name = content.get("fn") - args = content.get("args", {}) - - if not fn_name: - await websocket.send_json(_error_frame(request_id, BadRequest("Missing 'fn' field"))) - return - - fn_class = get_function(fn_name) - if fn_class is None: - await websocket.send_json(_error_frame(request_id, NotFound(f"Function '{fn_name}' not found"))) - return - if not getattr(fn_class, "_meta", {}).get("websocket"): - await websocket.send_json( - _error_frame( - request_id, - Forbidden("This function is HTTP-only. Use POST /api/mizan/call/ instead."), - ) - ) - return - - try: - res = await dispatch_call( - DispatchRequest(identity=identity, args=args, native_request=websocket), - fn_name, cfg.cache, - ) - except MizanError as exc: - await websocket.send_json(_error_frame(request_id, exc)) - return - - frame: dict[str, Any] = {"id": request_id, "ok": True, "data": res.data, - "invalidate": res.invalidate or []} - if res.merge: - frame["merge"] = res.merge - await websocket.send_json(frame) + params = dict(request.query_params) + bundled = {fn: await execute_function(request, fn, params) for fn in fn_names} + return _no_store(bundled) # ─── Exception handler ────────────────────────────────────────────────────── diff --git a/backends/mizan-fastapi/src/mizan_fastapi/shapes.py b/backends/mizan-fastapi/src/mizan_fastapi/shapes.py deleted file mode 100644 index 825cd8f..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/shapes.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Typed query projection (Shapes) — the SQLAlchemy binding. - -A Shape is a Pydantic model that declares *which* fields and relationships of an -ORM model to project. The declaration surface is identical to the Django -adapter's `mizan.shapes` (`django-readers` binding): - - class AuthorShape(Shape[Author]): - id: int - name: str - books: list[BookShape] = [] # nested relationship - - AuthorShape.query(session, lambda s: s.where(Author.name == "Ann")) - -Only the ORM binding differs: where the Django Shape lowers its spec to -`django-readers` pairs (queryset prepare + instance project), this lowers it to a -SQLAlchemy `select(Model)` with `selectinload(...)` eager-loading for each nested -relationship (the projection-load that keeps the query count flat), then projects -each loaded instance into the Pydantic shape. `.diff()` / `.diff_many()` compare a -constructed shape against current DB rows, mirroring the Django semantics. - -The one surface difference SQLAlchemy forces is an explicit `session` argument to -`query` / `diff` / `diff_many` — Django models carry an implicit `objects` -manager; a SQLAlchemy mapped class does not. That is the ORM binding, not the -Shape declaration. -""" - -from __future__ import annotations - -import types -from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints - -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.inspection import inspect as sa_inspect -from sqlalchemy.orm import Session, selectinload - -_M = TypeVar("_M") -_S = TypeVar("_S", bound="Shape") - - -def _extract_shape_class(hint) -> type[Shape] | None: - """The nested Shape a field annotation projects, if any. - - Handles `SomeShape`, `list[SomeShape]`, and `SomeShape | None` / Optional — - the same forms the Django binding's `_extract_shape_class` accepts. - """ - origin = getattr(hint, "__origin__", None) - args = getattr(hint, "__args__", ()) - - if origin is list and args and isinstance(args[0], type) and issubclass(args[0], Shape): - return args[0] - - if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape: - return hint - - if origin is Union or isinstance(hint, types.UnionType): - for arg in args: - if arg is type(None): - continue - if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape: - return arg - - return None - - -def _resolve_model(cls) -> Any | None: - """The mapped model a Shape subclass is parameterized on (`Shape[Model]`).""" - for base in cls.__bases__: - meta = getattr(base, "__pydantic_generic_metadata__", None) or {} - if meta.get("origin") is Shape and (args := meta.get("args")): - return args[0] - return None - - -class Shape(BaseModel, Generic[_M]): - """Typed projection over a SQLAlchemy mapped model. - - Subclass as `Shape[Model]`; annotate the fields/relationships to project. - Scalar annotations become columns to read; annotations referencing another - Shape become relationships to eager-load and project recursively. - """ - - _model: ClassVar[Any] - _nested: ClassVar[dict[str, type[Shape]]] - _field_names: ClassVar[list[str]] - _pk_field: ClassVar[str] - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - - if not (model := _resolve_model(cls)): - return - - mapper = sa_inspect(model) - cls._model = model - cls._nested = {} - pk_cols = mapper.primary_key - cls._pk_field = pk_cols[0].key if pk_cols else "id" - - hints = get_type_hints(cls, include_extras=False, localns={cls.__name__: cls}) or cls.__annotations__ - field_names: list[str] = [] - for name, hint in hints.items(): - if name.startswith("_"): - continue - if shape_cls := _extract_shape_class(hint): - cls._nested[name] = shape_cls - else: - field_names.append(name) - cls._field_names = field_names - - # ─── Loading + projection ──────────────────────────────────────────────── - - @classmethod - def _loader_options(cls) -> list[Any]: - """`selectinload(...)` chains for every nested relationship (recursive). - - This is the SQLAlchemy analogue of django-readers' prefetch wiring: each - nested Shape contributes a `selectinload` on its relationship attribute, - with the child Shape's own loader options nested beneath it, so the whole - projection loads in O(depth) queries rather than N+1. - """ - options: list[Any] = [] - for name, shape_cls in cls._nested.items(): - attr = getattr(cls._model, name) - child = shape_cls._loader_options() - loader = selectinload(attr) - options.append(loader.options(*child) if child else loader) - return options - - @classmethod - def _project(cls: type[_S], instance: Any) -> _S: - """Project a loaded ORM instance into this Shape (recursively for nested).""" - data: dict[str, Any] = {name: getattr(instance, name) for name in cls._field_names} - for name, shape_cls in cls._nested.items(): - related = getattr(instance, name) - if related is None: - data[name] = None - elif isinstance(related, (list, set, tuple)) or hasattr(related, "__iter__") and not isinstance(related, (str, bytes)): - data[name] = [shape_cls._project(child) for child in related] - else: - data[name] = shape_cls._project(related) - return cls.model_validate(data) - - @classmethod - def query(cls: type[_S], session: Session, *stmt_fns, **relation_stmt) -> list[_S]: - """Project the model into a list of shapes. - - Args: - session: An open SQLAlchemy `Session`. - *stmt_fns: Callables `(select) -> select` applied in order to the base - `select(Model)` — filters/ordering/limits (the SQLAlchemy analogue - of the Django binding's queryset functions). - **relation_stmt: Per-relationship callables `(select) -> select` whose - criteria scope a nested relationship's load (e.g. - ``books=lambda s: s.where(Book.is_published.is_(True))``). - - Returns: - A list of projected shape instances. - """ - stmt = select(cls._model) - - loaders = cls._loader_options_scoped(relation_stmt) - if loaders: - stmt = stmt.options(*loaders) - - for fn in stmt_fns: - stmt = fn(stmt) - - rows = session.execute(stmt).unique().scalars().all() - return [cls._project(obj) for obj in rows] - - @classmethod - def _loader_options_scoped(cls, relation_stmt: dict[str, Any]) -> list[Any]: - """`_loader_options`, but with caller-supplied criteria applied per relation.""" - if not relation_stmt: - return cls._loader_options() - options: list[Any] = [] - for name, shape_cls in cls._nested.items(): - attr = getattr(cls._model, name) - loader = selectinload(attr) - child = shape_cls._loader_options() - if child: - loader = loader.options(*child) - scope = relation_stmt.get(name) - if scope is not None: - # `selectinload(...).and_(...)` filters the related rows loaded. - criteria = scope(select(shape_cls._model)).whereclause - if criteria is not None: - loader = selectinload(attr.and_(criteria)) - if child: - loader = loader.options(*child) - options.append(loader) - return options - - @classmethod - def _get_pk(cls, instance) -> Any | None: - return getattr(instance, cls._pk_field, None) - - # ─── Diff ──────────────────────────────────────────────────────────────── - - @classmethod - def diff_many(cls: type[_S], session: Session, items: list[_S]) -> list[tuple[_S, "Diff"]]: - """Diff a batch of shapes against current DB state in one fetch. - - New items (no PK) diff against `None`; existing items batch-fetch by PK. - Raises if an item declares a PK that no row matches. - """ - pk_field = cls._pk_field - pk_map: dict[Any, _S] = {} - new_items: list[_S] = [] - for item in items: - pk = cls._get_pk(item) - (pk_map.__setitem__(pk, item) if pk is not None else new_items.append(item)) - - current_map: dict[Any, _S] = {} - if pk_map: - pk_col = getattr(cls._model, pk_field) - current = cls.query(session, lambda s, _c=pk_col: s.where(_c.in_(list(pk_map.keys())))) - current_map = {cls._get_pk(c): c for c in current} - - results: list[tuple[_S, Diff]] = [] - for item in new_items: - results.append((item, cls._diff_one(item, None))) - for pk, item in pk_map.items(): - current = current_map.get(pk) - if current is None: - raise LookupError(f"{cls._model.__name__} with {pk_field}={pk} does not exist") - results.append((item, cls._diff_one(item, current))) - return results - - @classmethod - def _diff_one(cls, incoming: _S, current: _S | None) -> "Diff": - pk_field = cls._pk_field - changed = ( - {k: getattr(incoming, k) for k in cls._field_names - if k != pk_field and getattr(incoming, k) != getattr(current, k)} - if current - else {k: getattr(incoming, k) for k in cls._field_names if k != pk_field} - ) - - nested: dict[str, NestedDiff] = {} - for name, shape_cls in cls._nested.items(): - incoming_items = getattr(incoming, name, None) or [] - current_items = (getattr(current, name, None) or []) if current else [] - if not isinstance(incoming_items, list): - incoming_items = [incoming_items] - if not isinstance(current_items, list): - current_items = [current_items] - - current_by_pk = {shape_cls._get_pk(c): c for c in current_items if shape_cls._get_pk(c) is not None} - incoming_by_pk = {shape_cls._get_pk(c): c for c in incoming_items if shape_cls._get_pk(c) is not None} - - nested[name] = NestedDiff( - created=[c for c in incoming_items if shape_cls._get_pk(c) is None], - updated=[c for pk, c in incoming_by_pk.items() if pk in current_by_pk and c != current_by_pk[pk]], - deleted=[pk for pk in current_by_pk if pk not in incoming_by_pk], - ) - - return Diff(is_new=current is None, changed=changed, _nested=nested) - - def diff(self, session: Session) -> "Diff": - """Diff this shape against its current DB row (or `None` if new).""" - cls = type(self) - pk = cls._get_pk(self) - if pk is not None: - pk_col = getattr(cls._model, cls._pk_field) - results = cls.query(session, lambda s: s.where(pk_col == pk)) - if not results: - raise LookupError(f"{cls._model.__name__} with {cls._pk_field}={pk} does not exist") - current = results[0] - else: - current = None - return cls._diff_one(self, current) - - -class NestedDiff: - __slots__ = ("created", "updated", "deleted") - - def __init__(self, created=(), updated=(), deleted=()): - self.created = list(created) - self.updated = list(updated) - self.deleted = list(deleted) - - -class Diff: - __slots__ = ("is_new", "changed", "_nested") - - def __init__(self, is_new: bool, changed: dict[str, Any], _nested: dict[str, NestedDiff]): - self.is_new = is_new - self.changed = changed - self._nested = _nested - - def nested(self, name: str) -> NestedDiff: - """Strict access to a nested diff. Raises `KeyError` for an unknown name.""" - if name not in self._nested: - valid = ", ".join(sorted(self._nested)) or "(none)" - raise KeyError(f"No nested diff for '{name}'. Valid nested shapes: {valid}") - return self._nested[name] - - def __getattr__(self, name: str) -> NestedDiff: - if name.startswith("_"): - raise AttributeError(name) - if name not in self._nested: - valid = ", ".join(sorted(self._nested)) or "(none)" - raise AttributeError(f"No nested diff for '{name}'. Valid nested shapes: {valid}") - return self._nested[name] diff --git a/backends/mizan-fastapi/src/mizan_fastapi/ssr.py b/backends/mizan-fastapi/src/mizan_fastapi/ssr.py deleted file mode 100644 index ed630ef..0000000 --- a/backends/mizan-fastapi/src/mizan_fastapi/ssr.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -SSR render path — FastAPI adapter surface over the shared Bun bridge. - -The SSR subprocess lifecycle and JSON-RPC wire protocol live in -`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine -backend, so instead of Django's `MizanTemplates` veneer this exposes an -`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs -in the persistent Bun worker — and returns an `HTMLResponse` with the rendered -markup plus the hydration payload the client reads on mount. - -Usage: - from mizan_fastapi.ssr import SSRRenderer - - ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"]) - - @app.get("/profile/{user_id}") - async def profile(user_id: int): - return ssr.render("components/Profile.tsx", {"user_id": user_id}) - -`render` resolves the template name to an absolute file path against `dirs` -(parity with Django's `DIRS`), then renders the component's default export. The -hydration wrapping matches the Django backend byte-for-byte so the same client -bundle hydrates either server. -""" - -from __future__ import annotations - -import json -import os -from typing import Any - -from fastapi.responses import HTMLResponse - -from mizan_core.ssr import SSRBridge - - -class SSRRenderer: - """Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge. - - One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge - serializes worker I/O); a single renderer can be shared across the app. - """ - - def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None: - self._dirs = list(dirs or []) - self._bridge = SSRBridge(worker_path=worker, timeout=timeout) - - def _resolve(self, template_name: str) -> str: - """Resolve a template name to an absolute file path against `dirs`. - - An already-absolute, existing path is used directly; otherwise each `dirs` - entry is tried in order (parity with Django's `DIRS` resolution). - """ - if os.path.isabs(template_name) and os.path.isfile(template_name): - return template_name - for dir_path in self._dirs: - candidate = os.path.join(dir_path, template_name) - if os.path.isfile(candidate): - return os.path.abspath(candidate) - raise FileNotFoundError( - f"SSR component '{template_name}' not found in dirs={self._dirs!r}" - ) - - def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str: - """Render the component to an HTML string (markup + hydration script).""" - props = dict(props or {}) - result = self._bridge.render(self._resolve(template_name), props) - hydration_json = json.dumps(props, sort_keys=True, default=str) - return ( - f'
{result.html}
' - f"" - ) - - def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse: - """Render the component and return a FastAPI `HTMLResponse`.""" - return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code) - - def shutdown(self) -> None: - """Stop the underlying Bun subprocess.""" - self._bridge.shutdown() diff --git a/backends/mizan-fastapi/tests/test_edge_manifest.py b/backends/mizan-fastapi/tests/test_edge_manifest.py deleted file mode 100644 index 3c98a24..0000000 --- a/backends/mizan-fastapi/tests/test_edge_manifest.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Edge-manifest + PSR behavior — the genuine capability behind the -`edge_manifest` and `psr` probes. - -Proves the FastAPI adapter emits the manifest the spec defines (contexts, -mutations, params, user_scoped, render_strategy, page_routes) by deriving it from -a real registry, and that `render_strategy` falls out of the user-scoped-param -rule: a context whose params overlap {user_id, user, owner_id, account_id} is -`dynamic_cached`, otherwise `psr`. -""" - -from __future__ import annotations - -import json -import subprocess -import sys - -import pytest -from fastapi.responses import Response - -import mizan_fastapi # registers the Starlette Response base for view-path detection -from mizan_core.client.function import client -from mizan_core.registry import clear_registry, register - -from mizan_fastapi import edge_manifest, generate_edge_manifest -from mizan_fastapi.manifest import render_strategies - - -@pytest.fixture(autouse=True) -def _clean_registry(): - clear_registry() - yield - clear_registry() - - -def _register(fn, name): - register(fn, name) - - -def test_user_scoped_context_is_dynamic_cached(): - @client(context="user") - def user_profile(request, user_id: int) -> dict: - return {"id": user_id} - - _register(user_profile, "user_profile") - - manifest = edge_manifest() - ctx = manifest["contexts"]["user"] - assert ctx["user_scoped"] is True - assert ctx["render_strategy"] == "dynamic_cached" - assert ctx["params"] == ["user_id"] - assert ctx["endpoints"] == ["/api/mizan/ctx/user/"] - - -def test_non_user_scoped_context_is_psr(): - @client(context="catalog") - def catalog_items(request, category: str) -> list[dict]: - return [{"category": category}] - - _register(catalog_items, "catalog_items") - - ctx = edge_manifest()["contexts"]["catalog"] - assert ctx["user_scoped"] is False - assert ctx["render_strategy"] == "psr" - - -def test_render_strategies_maps_each_context(): - @client(context="user") - def me(request, user_id: int) -> dict: - return {"id": user_id} - - @client(context="catalog") - def items(request) -> list[dict]: - return [] - - _register(me, "me") - _register(items, "items") - - strategies = render_strategies() - assert strategies == {"user": "dynamic_cached", "catalog": "psr"} - - -def test_mutation_records_affects_and_auto_scope(): - @client(context="user") - def user_profile(request, user_id: int) -> dict: - return {"id": user_id} - - @client(affects="user") - def rename(request, user_id: int, name: str) -> dict: - return {"ok": True} - - _register(user_profile, "user_profile") - _register(rename, "rename") - - mutation = edge_manifest()["mutations"]["rename"] - assert mutation["affects"] == ["user"] - # user_id matches the context's param → auto-scoped - assert mutation["auto_scoped_params"] == ["user_id"] - - -def test_private_and_route_mutation_carried(): - @client(affects="subscription", private=True, route="/webhooks/stripe/", methods=["POST"]) - def stripe_webhook(request) -> Response: - return Response(status_code=200) - - @client(context="subscription") - def subscription(request, user_id: int) -> dict: - return {"id": user_id} - - _register(stripe_webhook, "stripe_webhook") - _register(subscription, "subscription") - - mutation = edge_manifest()["mutations"]["stripe_webhook"] - assert mutation["private"] is True - assert mutation["route"] == "/webhooks/stripe/" - assert mutation["methods"] == ["POST"] - - -def test_view_path_function_records_route_and_page_routes(): - @client(context="profile", route="/profile//") - def profile_page(request, user_id: int) -> Response: - return Response(status_code=200) - - _register(profile_page, "profile_page") - - ctx = edge_manifest()["contexts"]["profile"] - assert ctx["page_routes"] == ["/profile//"] - fn_entry = next(f for f in ctx["functions"] if f["name"] == "profile_page") - assert fn_entry["path"] == "view" - assert fn_entry["route"] == "/profile//" - - -def test_fastapi_manifest_matches_core_derivation(): - """The adapter callable is a thin pass-through to the shared core derivation.""" - - @client(context="user") - def user_profile(request, user_id: int) -> dict: - return {"id": user_id} - - _register(user_profile, "user_profile") - - assert edge_manifest() == generate_edge_manifest(base_url="/api/mizan") - - -def test_cli_entry_emits_manifest_json(tmp_path): - """`mizan-fastapi-edge-manifest ` imports the module then prints JSON.""" - app_module = tmp_path / "manifest_app.py" - app_module.write_text( - "from mizan_core.client.function import client\n" - "from mizan_core.registry import register\n" - "@client(context='user')\n" - "def user_profile(request, user_id: int) -> dict:\n" - " return {'id': user_id}\n" - "register(user_profile, 'user_profile')\n", - encoding="utf-8", - ) - - result = subprocess.run( - [sys.executable, "-m", "mizan_fastapi.manifest", "manifest_app", "--indent", "0"], - cwd=tmp_path, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stderr - manifest = json.loads(result.stdout) - assert manifest["contexts"]["user"]["render_strategy"] == "dynamic_cached" diff --git a/backends/mizan-fastapi/tests/test_forms.py b/backends/mizan-fastapi/tests/test_forms.py deleted file mode 100644 index fa58cb1..0000000 --- a/backends/mizan-fastapi/tests/test_forms.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Forms behavior — the genuine capability behind the `forms` probe. - -Proves the Pydantic binding exposes the same schema / validate / submit role -contract as the Django adapter: subclassing `mizanForm` auto-registers -`{name}.schema`, `{name}.validate`, `{name}.submit` with the matching -`_meta["form_role"]`, the schema role emits typed field definitions, validate -returns structured field errors, and submit validates then runs -`on_submit_success` / `on_submit_failure`. -""" - -from __future__ import annotations - -import pytest - -from mizan_core.registry import clear_registry, get_function - -from mizan_fastapi.forms import ( - FormConfig, - FormSubmitFail, - FormSubmitPass, - FormValidation, - build_form_schema, - get_forms, - mizanForm, -) - - -@pytest.fixture(autouse=True) -def _clean(): - clear_registry() - yield - clear_registry() - - -def _make_contact_form(): - class ContactForm(mizanForm): - mizan = FormConfig(name="contact", title="Contact Us", submit_label="Send") - - name: str - email: str - message: str = "" - - def on_submit_success(self, request) -> dict: - return {"sent": True, "to": self.email} - - return ContactForm - - -def test_subclassing_registers_three_role_functions(): - _make_contact_form() - for role in ("schema", "validate", "submit"): - fn = get_function(f"contact.{role}") - assert fn is not None, f"contact.{role} not registered" - assert fn._meta["form"] is True - assert fn._meta["form_name"] == "contact" - assert fn._meta["form_role"] == role - - -def test_schema_role_emits_field_definitions(): - form_cls = _make_contact_form() - SchemaFn = get_function("contact.schema") - schema = SchemaFn(request=None).call(None) - assert schema.name == "contact" - assert schema.title == "Contact Us" - assert schema.submit_label == "Send" - field_names = {f.name for f in schema.fields} - assert field_names == {"name", "email", "message"} - # `message` has a default → not required; `name`/`email` required - by_name = {f.name: f for f in schema.fields} - assert by_name["name"].required is True - assert by_name["message"].required is False - - -def test_build_form_schema_maps_types(): - class TypedForm(mizanForm): - mizan = FormConfig(name="typed") - count: int - ratio: float - active: bool - label: str - - schema = build_form_schema(TypedForm) - by_name = {f.name: f for f in schema.fields} - assert by_name["count"].type == "number" - assert by_name["ratio"].type == "number" - assert by_name["active"].type == "checkbox" - assert by_name["label"].type == "text" - - -def test_validate_role_passes_clean_data(): - _make_contact_form() - ValidateFn = get_function("contact.validate") - ValidateInput = ValidateFn.Input - out = ValidateFn(request=None).call(ValidateInput(data={"name": "Ryth", "email": "r@x.com"})) - assert isinstance(out, FormValidation) - assert out.errors == [] - - -def test_validate_role_reports_field_errors(): - _make_contact_form() - ValidateFn = get_function("contact.validate") - ValidateInput = ValidateFn.Input - out = ValidateFn(request=None).call(ValidateInput(data={"email": "r@x.com"})) # missing 'name' - error_fields = {e.field for e in out.errors} - assert "name" in error_fields - - -def test_submit_role_runs_on_submit_success(): - _make_contact_form() - SubmitFn = get_function("contact.submit") - SubmitInput = SubmitFn.Input - result = SubmitFn(request=None).call( - SubmitInput(data={"name": "Ryth", "email": "ryth@example.com", "message": "hi"}) - ) - assert isinstance(result, FormSubmitPass) - assert result.success is True - assert result.data == {"sent": True, "to": "ryth@example.com"} - - -def test_submit_role_returns_fail_on_invalid(): - captured = {} - - class GuardedForm(mizanForm): - mizan = FormConfig(name="guarded") - name: str - - def on_submit_failure(self, request, errors) -> None: - captured["errors"] = errors - - SubmitFn = get_function("guarded.submit") - SubmitInput = SubmitFn.Input - result = SubmitFn(request=None).call(SubmitInput(data={})) # missing required 'name' - assert isinstance(result, FormSubmitFail) - assert result.success is False - assert any(e.field == "name" for e in result.errors.errors) - # on_submit_failure hook fired with the validation - assert "errors" in captured - - -def test_get_forms_groups_by_form_name(): - _make_contact_form() - forms = get_forms() - assert set(forms.keys()) == {"contact"} - assert len(forms["contact"]) == 3 diff --git a/backends/mizan-fastapi/tests/test_parity.py b/backends/mizan-fastapi/tests/test_parity.py deleted file mode 100644 index 66ce8f2..0000000 --- a/backends/mizan-fastapi/tests/test_parity.py +++ /dev/null @@ -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 diff --git a/backends/mizan-fastapi/tests/test_shapes.py b/backends/mizan-fastapi/tests/test_shapes.py deleted file mode 100644 index 9129c97..0000000 --- a/backends/mizan-fastapi/tests/test_shapes.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -Shapes behavior — the genuine capability behind the `shapes` probe. - -Proves the SQLAlchemy binding has the same Shape declaration surface and -projection/diff semantics as the Django `django-readers` binding: - -- `Shape[Model]` resolves the mapped model + PK from the generic arg; -- scalar annotations project columns, Shape-typed annotations project relations; -- `.query(session, *stmt_fns, **relation_stmt)` flat / nested / scoped; -- nested loads stay flat (selectinload, not N+1); -- `.diff()` / `.diff_many()` detect field changes + nested created/updated/deleted. -""" - -from __future__ import annotations - -import pytest -from sqlalchemy import ForeignKey, create_engine, event -from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship - -from mizan_fastapi.shapes import Diff, NestedDiff, Shape - - -# ─── Mapped models ──────────────────────────────────────────────────────────── - - -class Base(DeclarativeBase): - pass - - -class Publisher(Base): - __tablename__ = "publisher" - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] - country: Mapped[str] - authors: Mapped[list["Author"]] = relationship(back_populates="publisher") - - -class Author(Base): - __tablename__ = "author" - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] - bio: Mapped[str] = mapped_column(default="") - publisher_id: Mapped[int] = mapped_column(ForeignKey("publisher.id")) - publisher: Mapped[Publisher] = relationship(back_populates="authors") - books: Mapped[list["Book"]] = relationship(back_populates="author") - - -class Book(Base): - __tablename__ = "book" - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] - is_published: Mapped[bool] = mapped_column(default=True) - author_id: Mapped[int] = mapped_column(ForeignKey("author.id")) - author: Mapped[Author] = relationship(back_populates="books") - - -# ─── Shapes (declaration surface identical to the Django adapter) ────────────── - - -class FlatAuthorShape(Shape[Author]): - id: int | None = None - name: str - - -class FlatBookShape(Shape[Book]): - id: int | None = None - title: str - is_published: bool - - -class BookCardShape(Shape[Book]): - id: int | None = None - title: str - is_published: bool - author: FlatAuthorShape # single nested FK - - -class AuthorCardShape(Shape[Author]): - id: int | None = None - name: str - bio: str - books: list[FlatBookShape] = [] # list nested reverse-FK - - -class PublisherDetailShape(Shape[Publisher]): - id: int | None = None - name: str - authors: list[AuthorCardShape] = [] # 3-level nesting - - -# ─── Fixtures ────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def session(): - engine = create_engine("sqlite://") - Base.metadata.create_all(engine) - with Session(engine) as s: - pub = Publisher(name="Orbit", country="UK") - ann = Author(name="Ann Leckie", bio="Imperial Radch", publisher=pub) - devi = Author(name="Devi Pillai", bio="", publisher=pub) - ann.books = [ - Book(title="Ancillary Justice", is_published=True), - Book(title="Provenance", is_published=False), - ] - s.add_all([pub, ann, devi]) - s.commit() - yield s - - -# ─── Declaration ──────────────────────────────────────────────────────────────── - - -def test_shape_resolves_model_and_pk(): - assert FlatAuthorShape._model is Author - assert FlatAuthorShape._pk_field == "id" - - -def test_flat_shape_has_no_nested(): - assert FlatAuthorShape._nested == {} - assert FlatAuthorShape._field_names == ["id", "name"] - - -def test_single_nested_detected(): - assert BookCardShape._nested == {"author": FlatAuthorShape} - - -def test_list_nested_detected(): - assert AuthorCardShape._nested == {"books": FlatBookShape} - - -# ─── Query ────────────────────────────────────────────────────────────────────── - - -def test_flat_query_projects_fields(session): - authors = FlatAuthorShape.query(session) - assert len(authors) == 2 - assert {a.name for a in authors} == {"Ann Leckie", "Devi Pillai"} - - -def test_query_with_stmt_fn_filters(session): - authors = FlatAuthorShape.query(session, lambda s: s.where(Author.name == "Ann Leckie")) - assert [a.name for a in authors] == ["Ann Leckie"] - - -def test_single_nested_fk_projected(session): - books = BookCardShape.query(session, lambda s: s.where(Book.title == "Ancillary Justice")) - assert len(books) == 1 - assert books[0].author.name == "Ann Leckie" - - -def test_list_nested_reverse_fk_projected(session): - authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Ann Leckie")) - assert len(authors) == 1 - assert {b.title for b in authors[0].books} == {"Ancillary Justice", "Provenance"} - - -def test_empty_nested_list(session): - authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Devi Pillai")) - assert authors[0].books == [] - - -def test_three_level_nesting(session): - pubs = PublisherDetailShape.query(session) - assert len(pubs) == 1 - leckie = next(a for a in pubs[0].authors if a.name == "Ann Leckie") - assert len(leckie.books) == 2 - - -def test_relation_stmt_scopes_nested_load(session): - authors = AuthorCardShape.query( - session, - lambda s: s.where(Author.name == "Ann Leckie"), - books=lambda s: s.where(Book.is_published.is_(True)), - ) - assert [b.title for b in authors[0].books] == ["Ancillary Justice"] - assert all(b.is_published for b in authors[0].books) - - -def test_nested_query_stays_flat(session): - """selectinload keeps the projection at O(depth) queries, not N+1.""" - counter = {"n": 0} - - @event.listens_for(session.bind, "after_cursor_execute") - def _count(*args): - counter["n"] += 1 - - AuthorCardShape.query(session) - # one query for authors + one selectin for books - assert counter["n"] == 2 - - -# ─── Diff ───────────────────────────────────────────────────────────────────── - - -def test_diff_no_changes(session): - book = session.query(Book).filter_by(title="Ancillary Justice").one() - shape = FlatBookShape(id=book.id, title="Ancillary Justice", is_published=True) - d = shape.diff(session) - assert d.is_new is False - assert d.changed == {} - - -def test_diff_detects_field_change(session): - book = session.query(Book).filter_by(title="Ancillary Justice").one() - shape = FlatBookShape(id=book.id, title="Ancillary Justice (rev)", is_published=True) - d = shape.diff(session) - assert d.changed["title"] == "Ancillary Justice (rev)" - - -def test_diff_new_item(session): - shape = FlatBookShape(id=None, title="Elantris", is_published=True) - d = shape.diff(session) - assert d.is_new is True - assert "title" in d.changed - - -def test_diff_nonexistent_pk_raises(session): - shape = FlatBookShape(id=999999, title="Ghost", is_published=False) - with pytest.raises(LookupError): - shape.diff(session) - - -def test_nested_diff_created_updated_deleted(session): - author = session.query(Author).filter_by(name="Ann Leckie").one() - books = sorted(author.books, key=lambda b: b.title) - # keep one (updated), drop one (deleted), add one (created) - shape = AuthorCardShape( - id=author.id, - name="Ann Leckie", - bio="Imperial Radch", - books=[ - FlatBookShape(id=books[0].id, title="Ancillary Justice REWRITTEN", is_published=True), - FlatBookShape(id=None, title="Ancillary Sword", is_published=True), - ], - ) - d = shape.diff(session) - assert len(d.books.updated) == 1 - assert len(d.books.created) == 1 - assert len(d.books.deleted) == 1 - - -def test_diff_strict_nested_access_raises_on_typo(session): - author = session.query(Author).filter_by(name="Ann Leckie").one() - shape = FlatAuthorShape(id=author.id, name="Ann Leckie") - d = shape.diff(session) - with pytest.raises(AttributeError): - _ = d.bookz - with pytest.raises(KeyError): - d.nested("bookz") - - -def test_diff_many_batches(session): - books = session.query(Book).all() - items = [FlatBookShape(id=b.id, title=b.title + "!", is_published=b.is_published) for b in books] - results = FlatBookShape.diff_many(session, items) - assert len(results) == len(books) - assert all("title" in d.changed for _, d in results) - - -def test_diff_many_mixed_new_and_existing(session): - book = session.query(Book).first() - items = [ - FlatBookShape(id=book.id, title=book.title, is_published=book.is_published), - FlatBookShape(id=None, title="Brand New", is_published=False), - ] - results = FlatBookShape.diff_many(session, items) - assert sum(1 for _, d in results if d.is_new) == 1 - assert sum(1 for _, d in results if not d.is_new) == 1 diff --git a/backends/mizan-fastapi/tests/test_ssr.py b/backends/mizan-fastapi/tests/test_ssr.py deleted file mode 100644 index eacc252..0000000 --- a/backends/mizan-fastapi/tests/test_ssr.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -SSR behavior — the genuine capability behind the `ssr_bridge` probe. - -The SSR subprocess lifecycle + JSON-RPC protocol live in the shared -`mizan_core.ssr.SSRBridge`; the FastAPI `SSRRenderer` resolves a component path -against `dirs`, drives the bridge, and wraps the result with the hydration script -the client reads on mount. - -Bun is not assumed present in CI, so the bridge is driven against a stand-in -worker that speaks the SAME newline-delimited JSON-RPC protocol (ready signal + -`render` → `{id, html}`). That exercises the real bridge code path (spawn, -message-ID correlation, threaded reader) — only the renderer binary is swapped. -The path-resolution and hydration-wrapping are tested directly. -""" - -from __future__ import annotations - -import sys -import textwrap - -import pytest - -from mizan_core.ssr import SSRBridge -from mizan_fastapi.ssr import SSRRenderer - - -# A Python stand-in for the Bun worker: emits the ready signal, then for each -# render request echoes a deterministic HTML fragment built from the props. -_FAKE_WORKER = textwrap.dedent( - """ - import json, sys - sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\\n"); sys.stdout.flush() - for line in sys.stdin: - line = line.strip() - if not line: - continue - msg = json.loads(line) - if msg.get("method") == "render": - props = msg["params"]["props"] - html = "

" + props.get("name", "") + "

" - sys.stdout.write(json.dumps({"id": msg["id"], "html": html}) + "\\n") - sys.stdout.flush() - """ -) - - -@pytest.fixture -def fake_worker(tmp_path): - worker = tmp_path / "fake_worker.py" - worker.write_text(_FAKE_WORKER, encoding="utf-8") - return str(worker) - - -@pytest.fixture -def python_bridge(fake_worker, monkeypatch): - """An `SSRBridge` whose subprocess is python (not bun), driving the fake worker.""" - import subprocess - - real_popen = subprocess.Popen - - def fake_popen(cmd, *args, **kwargs): - # Swap the `bun run ` invocation for `python `. - if cmd[:2] == ["bun", "run"]: - cmd = [sys.executable, cmd[2]] - return real_popen(cmd, *args, **kwargs) - - monkeypatch.setattr(subprocess, "Popen", fake_popen) - bridge = SSRBridge(worker_path=fake_worker, timeout=5.0) - yield bridge - bridge.shutdown() - - -def test_bridge_round_trips_render(python_bridge): - result = python_bridge.render("/abs/Hello.tsx", {"name": "World"}) - assert result.html == "

World

" - - -def test_bridge_correlates_concurrent_renders(python_bridge): - # Two renders on the persistent subprocess return their own results. - a = python_bridge.render("/abs/A.tsx", {"name": "A"}) - b = python_bridge.render("/abs/B.tsx", {"name": "B"}) - assert (a.html, b.html) == ("

A

", "

B

") - - -def test_renderer_resolves_against_dirs_and_wraps_hydration(fake_worker, monkeypatch, tmp_path): - import subprocess - - real_popen = subprocess.Popen - monkeypatch.setattr( - subprocess, "Popen", - lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k), - ) - - components = tmp_path / "frontend" - components.mkdir() - (components / "Hello.tsx").write_text("export default () => null", encoding="utf-8") - - renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)]) - try: - html = renderer.render_to_string("Hello.tsx", {"name": "Mizan"}) - finally: - renderer.shutdown() - - assert '

Mizan

' in html - assert 'window.__MIZAN_SSR_DATA__={"name": "Mizan"}' in html - - -def test_renderer_returns_html_response(fake_worker, monkeypatch, tmp_path): - import subprocess - from fastapi.responses import HTMLResponse - - real_popen = subprocess.Popen - monkeypatch.setattr( - subprocess, "Popen", - lambda cmd, *a, **k: real_popen([sys.executable, cmd[2]] if cmd[:2] == ["bun", "run"] else cmd, *a, **k), - ) - - components = tmp_path / "frontend" - components.mkdir() - (components / "Card.tsx").write_text("export default () => null", encoding="utf-8") - - renderer = SSRRenderer(worker=fake_worker, dirs=[str(components)]) - try: - response = renderer.render("Card.tsx", {"name": "x"}) - finally: - renderer.shutdown() - - assert isinstance(response, HTMLResponse) - assert response.status_code == 200 - - -def test_renderer_raises_on_missing_component(fake_worker, tmp_path): - renderer = SSRRenderer(worker=fake_worker, dirs=[str(tmp_path)]) - try: - with pytest.raises(FileNotFoundError): - renderer.render_to_string("Nope.tsx", {}) - finally: - renderer.shutdown() diff --git a/backends/mizan-fastapi/tests/test_upload.py b/backends/mizan-fastapi/tests/test_upload.py deleted file mode 100644 index 731ec54..0000000 --- a/backends/mizan-fastapi/tests/test_upload.py +++ /dev/null @@ -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 diff --git a/backends/mizan-fastapi/tests/test_websocket.py b/backends/mizan-fastapi/tests/test_websocket.py deleted file mode 100644 index 7a503d9..0000000 --- a/backends/mizan-fastapi/tests/test_websocket.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -WebSocket RPC behavior — the genuine capability behind the `websocket` probe. - -Proves the `/ws/` route dispatches `@client(websocket=True)` functions through -the SAME `mizan_core.dispatch` core as `POST /call/`: input validation, the -`{result, invalidate, merge}` envelope, `auth=` enforcement, and the -websocket=True gate that rejects HTTP-only functions. The frame protocol matches -mizan-django's Channels consumer (`action:"rpc"` → `{id, ok, data|error}`). -""" - -from __future__ import annotations - -import pytest -from fastapi import FastAPI -from fastapi.exceptions import RequestValidationError -from fastapi.testclient import TestClient -from pydantic import BaseModel - -from mizan_core.client.function import client -from mizan_core.registry import clear_registry, register -from mizan_fastapi import ( - MizanError, - mizan_exception_handler, - mizan_validation_handler, - router as mizan_router, -) - - -class EchoOut(BaseModel): - message: str - - -@pytest.fixture -def app(): - clear_registry() - - @client(websocket=True) - def ws_echo(request, text: str) -> EchoOut: - return EchoOut(message=f"ws: {text}") - - @client(websocket=True) - def ws_add(request, a: int, b: int) -> dict: - return {"total": a + b} - - @client(websocket=True, affects="user") - def ws_update(request, user_id: int) -> dict: - return {"ok": True} - - @client(websocket=True, auth=True) - def ws_secret(request) -> dict: - return {"secret": True} - - @client # HTTP-only — must be rejected over WS - def http_only(request) -> dict: - return {"http": True} - - for fn, name in ( - (ws_echo, "ws_echo"), (ws_add, "ws_add"), (ws_update, "ws_update"), - (ws_secret, "ws_secret"), (http_only, "http_only"), - ): - register(fn, name) - - fastapi_app = FastAPI() - fastapi_app.include_router(mizan_router, prefix="/api/mizan") - fastapi_app.add_exception_handler(MizanError, mizan_exception_handler) - fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler) - yield fastapi_app - clear_registry() - - -@pytest.fixture -def http(app): - return TestClient(app) - - -def test_ws_rpc_dispatches_and_returns_data(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "1", "fn": "ws_echo", "args": {"text": "hi"}}) - frame = ws.receive_json() - assert frame == {"id": "1", "ok": True, "data": {"message": "ws: hi"}, "invalidate": []} - - -def test_ws_rpc_validates_input_through_core(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "2", "fn": "ws_add", "args": {"a": "nope", "b": 3}}) - frame = ws.receive_json() - assert frame["ok"] is False - assert frame["error"]["code"] == "VALIDATION_ERROR" - - -def test_ws_rpc_carries_invalidation(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "3", "fn": "ws_update", "args": {"user_id": 5}}) - frame = ws.receive_json() - assert frame["ok"] is True - assert "user" in frame["invalidate"] - - -def test_http_only_function_is_forbidden_over_ws(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "4", "fn": "http_only", "args": {}}) - frame = ws.receive_json() - assert frame["ok"] is False - assert frame["error"]["code"] == "FORBIDDEN" - - -def test_unknown_function_over_ws_is_not_found(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "5", "fn": "ghost", "args": {}}) - frame = ws.receive_json() - assert frame["ok"] is False - assert frame["error"]["code"] == "NOT_FOUND" - - -def test_auth_required_function_rejects_anonymous_over_ws(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "6", "fn": "ws_secret", "args": {}}) - frame = ws.receive_json() - assert frame["ok"] is False - assert frame["error"]["code"] == "UNAUTHORIZED" - - -def test_missing_fn_field_is_bad_request(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "7"}) - frame = ws.receive_json() - assert frame["ok"] is False - assert frame["error"]["code"] == "BAD_REQUEST" - - -def test_unknown_action_errors(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "bogus"}) - frame = ws.receive_json() - assert "error" in frame - - -def test_multiple_calls_on_one_connection(http): - with http.websocket_connect("/api/mizan/ws/") as ws: - ws.send_json({"action": "rpc", "id": "a", "fn": "ws_echo", "args": {"text": "1"}}) - first = ws.receive_json() - ws.send_json({"action": "rpc", "id": "b", "fn": "ws_echo", "args": {"text": "2"}}) - second = ws.receive_json() - assert first["data"]["message"] == "ws: 1" - assert second["data"]["message"] == "ws: 2" diff --git a/backends/mizan-rust-axum/Cargo.lock b/backends/mizan-rust-axum/Cargo.lock index 4a29ea1..59bfc10 100644 --- a/backends/mizan-rust-axum/Cargo.lock +++ b/backends/mizan-rust-axum/Cargo.lock @@ -27,7 +27,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", "bytes", "futures-util", "http", @@ -39,7 +38,6 @@ dependencies = [ "matchit", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -47,10 +45,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -78,90 +74,18 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -186,23 +110,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -216,49 +123,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-macro", - "futures-sink", "futures-task", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.4.0" @@ -411,15 +286,10 @@ name = "mizan-axum" version = "0.1.0" dependencies = [ "axum", - "base64", - "futures-util", - "http-body-util", "mizan-core", - "multer", "serde", "serde_json", "tokio", - "tokio-tungstenite", "tower", "tower-http", ] @@ -429,13 +299,10 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", - "base64", - "hmac", "linkme", "mizan-macros", "serde", "serde_json", - "sha2", ] [[package]] @@ -448,23 +315,6 @@ dependencies = [ "syn", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -483,15 +333,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -510,36 +351,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -618,28 +429,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "slab" version = "0.4.12" @@ -662,18 +451,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -691,33 +468,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "bytes", "libc", "mio", "pin-project-lite", @@ -737,18 +493,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tower" version = "0.5.3" @@ -813,48 +557,12 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -876,26 +584,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "zerocopy" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/backends/mizan-rust-axum/Cargo.toml b/backends/mizan-rust-axum/Cargo.toml index f974c58..62b89ad 100644 --- a/backends/mizan-rust-axum/Cargo.toml +++ b/backends/mizan-rust-axum/Cargo.toml @@ -7,17 +7,9 @@ license = "Elastic-2.0" [dependencies] mizan-core = { path = "../../cores/mizan-rust" } -axum = { version = "0.7", features = ["ws", "multipart"] } +axum = "0.7" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower = "0.5" tower-http = { version = "0.6", features = ["trace"] } -futures-util = "0.3" -multer = "3" -base64 = "0.22" - -[dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } -tokio-tungstenite = "0.24" -http-body-util = "0.1" diff --git a/backends/mizan-rust-axum/src/forms.rs b/backends/mizan-rust-axum/src/forms.rs deleted file mode 100644 index 10ef9f5..0000000 --- a/backends/mizan-rust-axum/src/forms.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Forms endpoints — schema / validate / submit over the registered form -//! functions. The Forms capability is AFI-common; the binding is -//! per-framework (Django Forms on Django, a `#[mizan(form_name=…, -//! form_role=…)]` function here). A form is the set of registered functions -//! sharing a `form_name`, each carrying one `form_role`; each role gets its -//! own route that dispatches the function whose `(form_name, form_role)` -//! matches. -//! -//! POST /form/:form_name/schema/ -//! POST /form/:form_name/validate/ -//! POST /form/:form_name/submit/ - -use axum::extract::{Path, State}; -use axum::http::{header, HeaderValue, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use mizan_core::{FunctionSpec, MizanError, RequestHandle, FUNCTIONS}; -use serde_json::{Map, Value}; -use std::sync::Arc; - -use crate::errors::ApiError; -use crate::state::MizanState; - -/// Find the registered form function with this `(form_name, form_role)`. -fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> { - FUNCTIONS - .iter() - .copied() - .find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role)) -} - -/// Dispatch the form function for `(form_name, role)`. Shared by the three -/// role routes below. -async fn dispatch_role( - state: &MizanState, - form_name: &str, - role: &str, - args: Value, -) -> Result { - let fn_spec = lookup_form_fn(form_name, role).ok_or_else(|| { - ApiError(MizanError::NotFound(format!( - "no form {form_name:?} with role {role:?}" - ))) - })?; - - let args_value = match args { - Value::Object(_) | Value::Null => args, - other => Value::Object({ - let mut m = Map::new(); - m.insert("data".into(), other); - m - }), - }; - - let req = RequestHandle::from_dyn(state.app_state.as_ref()); - let result = fn_spec.dispatch(req, args_value).await.map_err(ApiError)?; - - let mut resp = (StatusCode::OK, Json(result)).into_response(); - resp.headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - Ok(resp) -} - -/// POST /form/:form_name/schema/ — the form's field/schema descriptor. -pub async fn form_schema( - State(state): State>, - Path(form_name): Path, - Json(args): Json, -) -> Result { - dispatch_role(&state, &form_name, "schema", args).await -} - -/// POST /form/:form_name/validate/ — validate submitted data without committing. -pub async fn form_validate( - State(state): State>, - Path(form_name): Path, - Json(args): Json, -) -> Result { - dispatch_role(&state, &form_name, "validate", args).await -} - -/// POST /form/:form_name/submit/ — validate and commit the form. -pub async fn form_submit( - State(state): State>, - Path(form_name): Path, - Json(args): Json, -) -> Result { - dispatch_role(&state, &form_name, "submit", args).await -} diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs index 06abc32..c1cbe19 100644 --- a/backends/mizan-rust-axum/src/handlers.rs +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -1,22 +1,25 @@ -//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py` -//! and rides the shared `mizan-core` dispatch/auth/cache/invalidation logic. +//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`. use axum::extract::{Path, Query, State}; -use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use mizan_core::{ - authenticate, compute_invalidation, compute_merges, enforce_auth, format_invalidate_header, - lookup_context, lookup_function, shapes, AuthOutcome, AuthRequirement, FunctionSpec, Identity, + compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::any::Any; use std::collections::BTreeMap; use std::sync::Arc; use crate::errors::ApiError; -use crate::state::MizanState; + +/// Type-erased application state threaded into every `dispatch()` call via +/// `RequestHandle`. User handlers downcast to their concrete state type. +/// `Arc` keeps the clone cheap across per-request handler invocations. +pub type AppStateAny = Arc; /// Body for POST /call/. Matches the Python `CallBody` shape. #[derive(Debug, Deserialize)] @@ -30,7 +33,9 @@ pub struct CallBody { impl CallBody { fn resolved_name(&self) -> Option<&str> { - self.function_name.as_deref().or(self.fn_.as_deref()) + self.function_name + .as_deref() + .or(self.fn_.as_deref()) } } @@ -49,210 +54,44 @@ fn no_store(json: Value) -> Response { resp } -/// Resolve the request identity from `X-Mizan-Token` / `Authorization: Bearer` -/// through the shared `authenticate`. A present-but-invalid token rejects with -/// 401 (the `INVALID` contract); no token → anonymous (`None`). -pub(crate) fn identity_from_headers( - headers: &HeaderMap, - state: &MizanState, -) -> Result, ApiError> { - let mwt = headers - .get("X-Mizan-Token") - .and_then(|v| v.to_str().ok()); - let bearer = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()); - match authenticate(mwt, bearer, &state.auth, mizan_core::now_unix()) { - AuthOutcome::Authenticated(id) => Ok(Some(id)), - AuthOutcome::Anonymous => Ok(None), - AuthOutcome::Invalid => Err(ApiError(MizanError::Unauthorized( - "Invalid or expired token".into(), - ))), - } -} - -/// Enforce a function's `@client(auth=...)` against the resolved identity. -fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), ApiError> { - let req = AuthRequirement::from_str_opt(fn_spec.auth()); - enforce_auth(identity, &req).map_err(ApiError) -} - -/// Reject a client call into a `private` function (no RPC endpoint). -fn reject_if_private(fn_spec: &dyn FunctionSpec) -> Result<(), ApiError> { - if fn_spec.private() { - return Err(ApiError(MizanError::Forbidden( - "Function is not client-callable".into(), - ))); - } - Ok(()) -} - -fn uid_str(identity: Option<&Identity>) -> Option { - identity.map(|i| i.user_id.clone()) -} - -/// POST /call/ — RPC dispatch (JSON or multipart). Emits the invalidate body -/// AND the `X-Mizan-Invalidate` header; purges the origin cache for the -/// invalidated contexts. +/// POST /call/ — RPC dispatch. pub async fn function_call( - State(state): State>, - headers: HeaderMap, - body: axum::body::Body, + State(app_state): State, + Json(body): Json, ) -> Result { - let identity = identity_from_headers(&headers, &state)?; - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") + let fn_name = body + .resolved_name() + .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? .to_string(); - let (fn_name, args) = if content_type.starts_with("multipart/form-data") { - parse_multipart(&content_type, body).await? - } else { - parse_json_call(body).await? - }; + let fn_spec = lookup_function(&fn_name) + .ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?; - let fn_spec = lookup_function(&fn_name).ok_or_else(|| { - ApiError(MizanError::NotFound(format!( - "function {fn_name:?} not registered" - ))) - })?; - reject_if_private(fn_spec)?; - guard(fn_spec, identity.as_ref())?; + let req = RequestHandle::from_dyn(app_state.as_ref()); + let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?; - let req = RequestHandle::from_dyn(state.app_state.as_ref()); - let result = fn_spec - .dispatch(req, Value::Object(args.clone())) - .await - .map_err(ApiError)?; - - let targets = compute_invalidation(fn_spec, &args); - let invalidate: Vec = targets.iter().map(InvalidationTarget::to_json).collect(); - let merges = compute_merges(fn_spec, &args, &result); + let invalidate: Vec = compute_invalidation(fn_spec, &body.args) + .iter() + .map(InvalidationTarget::to_json) + .collect(); + let merges = compute_merges(fn_spec, &body.args, &result); let merge_payload: Option> = if merges.is_empty() { None } else { Some(merges.iter().map(MergeEntry::to_json).collect()) }; - // Purge the origin cache for everything this mutation invalidated. - if !targets.is_empty() { - state.cache.purge(&targets, uid_str(identity.as_ref()).as_deref()); - } - let payload = CallResponse { result, invalidate, merge: merge_payload, }; - let mut resp = no_store(serde_json::to_value(&payload).unwrap()); - if !targets.is_empty() { - let header_val = format_invalidate_header(&targets); - if let Ok(hv) = HeaderValue::from_str(&header_val) { - resp.headers_mut().insert("X-Mizan-Invalidate", hv); - } - } - Ok(resp) + Ok(no_store(serde_json::to_value(&payload).unwrap())) } -async fn parse_json_call(body: axum::body::Body) -> Result<(String, Map), ApiError> { - let bytes = axum::body::to_bytes(body, usize::MAX) - .await - .map_err(|e| ApiError(MizanError::BadRequest(format!("body read failed: {e}"))))?; - let call: CallBody = serde_json::from_slice(&bytes) - .map_err(|_| ApiError(MizanError::BadRequest("Invalid request body".into())))?; - let fn_name = call - .resolved_name() - .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? - .to_string(); - Ok((fn_name, call.args)) -} - -/// Parse a multipart `/call/` request: a JSON `args` field plus file parts. -/// Each file part binds into the matching Upload-typed input field as a -/// base64-carrying value the `mizan_core::Upload` field deserializes. -async fn parse_multipart( - content_type: &str, - body: axum::body::Body, -) -> Result<(String, Map), ApiError> { - let boundary = multer::parse_boundary(content_type) - .map_err(|_| ApiError(MizanError::BadRequest("missing multipart boundary".into())))?; - let stream = body.into_data_stream(); - let mut mp = multer::Multipart::new(stream, boundary); - - let mut fn_name: Option = None; - let mut args: Map = Map::new(); - let mut files: BTreeMap> = BTreeMap::new(); - - while let Some(field) = mp - .next_field() - .await - .map_err(|e| ApiError(MizanError::BadRequest(format!("multipart error: {e}"))))? - { - let name = field.name().unwrap_or("").to_string(); - let filename = field.file_name().map(|s| s.to_string()); - let part_content_type = field.content_type().map(|s| s.to_string()); - - if filename.is_some() { - // A file part → the JSON shape `mizan_core::Upload` deserializes - // (filename, content_type, base64 bytes). - let data = field - .bytes() - .await - .map_err(|e| ApiError(MizanError::BadRequest(format!("file read: {e}"))))?; - files.entry(name).or_default().push(uploaded_file_json( - filename, - part_content_type, - &data, - )); - } else { - let text = field - .text() - .await - .map_err(|e| ApiError(MizanError::BadRequest(format!("field read: {e}"))))?; - if name == "fn" { - fn_name = Some(text); - } else if name == "args" { - let parsed: Value = serde_json::from_str(&text).map_err(|_| { - ApiError(MizanError::BadRequest("Invalid JSON in 'args' field".into())) - })?; - if let Value::Object(m) = parsed { - args = m; - } - } - } - } - - // Bind file parts into args by field name (single vs list). - for (field_name, parts) in files { - if parts.len() == 1 { - args.insert(field_name, parts.into_iter().next().unwrap()); - } else { - args.insert(field_name, Value::Array(parts)); - } - } - - let fn_name = - fn_name.ok_or_else(|| ApiError(MizanError::BadRequest("Missing 'fn' field".into())))?; - Ok((fn_name, args)) -} - -/// Encode a received file part as the JSON shape an `Upload` field expects. -fn uploaded_file_json(filename: Option, content_type: Option, data: &[u8]) -> Value { - use base64::engine::general_purpose::STANDARD; - use base64::Engine; - serde_json::json!({ - "filename": filename, - "content_type": content_type, - "data_b64": STANDARD.encode(data), - "size": data.len(), - }) -} - -/// GET /ctx/:context_name/ — bundled context fetch, origin-cached. +/// GET /ctx/:context_name/ — bundled context fetch. pub async fn context_fetch( - State(state): State>, - headers: HeaderMap, + State(app_state): State, Path(context_name): Path, Query(params): Query>, ) -> Result { @@ -262,8 +101,6 @@ pub async fn context_fetch( )))); } - let identity = identity_from_headers(&headers, &state)?; - let members: Vec<&dyn FunctionSpec> = FUNCTIONS .iter() .copied() @@ -275,130 +112,22 @@ pub async fn context_fetch( )))); } - // Origin cache: the canonical-JSON bundle body is keyed by (context, - // params, user, rev). The Rust IR carries no per-fn rev yet → rev 0. - let cache_params: BTreeMap = params - .iter() - .map(|(k, v)| (k.clone(), Value::String(v.clone()))) - .collect(); - let uid = uid_str(identity.as_ref()); - - if let Some(cached) = state - .cache - .get(&context_name, &cache_params, uid.as_deref(), 0) - { - return Ok(cached_response(cached, "HIT")); - } - - // Enforce auth per member (the bundle is only as open as its strictest fn). + // Convert query params (all-string values) to the JSON arg map. Numeric + // params get parsed via the per-function input_params primitive table. let mut bundled = Map::new(); for fn_spec in &members { - guard(*fn_spec, identity.as_ref())?; let args = coerce_query_args(*fn_spec, ¶ms); - let req = RequestHandle::from_dyn(state.app_state.as_ref()); - let result = fn_spec - .dispatch(req, Value::Object(args)) - .await - .map_err(ApiError)?; + let req = RequestHandle::from_dyn(app_state.as_ref()); + let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?; bundled.insert(fn_spec.name().to_string(), result); } - let body = canonical_bytes(&Value::Object(bundled)); - let status = if state.cache.enabled() { - state - .cache - .put(&context_name, &cache_params, body.clone(), uid.as_deref(), 0); - "MISS" - } else { - "" - }; - Ok(cached_response(body, status)) + Ok(no_store(Value::Object(bundled))) } -/// Canonical JSON bytes for the cache body — sorted keys, matching Python's -/// `json.dumps(data, sort_keys=True)` so a cached body is reproducible. -fn canonical_bytes(v: &Value) -> Vec { - fn sort(v: &Value) -> Value { - match v { - Value::Object(m) => { - let mut keys: Vec<&String> = m.keys().collect(); - keys.sort(); - let mut out = Map::new(); - for k in keys { - out.insert(k.clone(), sort(&m[k])); - } - Value::Object(out) - } - Value::Array(a) => Value::Array(a.iter().map(sort).collect()), - other => other.clone(), - } - } - // Python's default separators add a space after ':' and ','. Match that so - // a Rust-written cache body and a Python-written one are byte-equal. - let sorted = sort(v); - python_json(&sorted) -} - -/// Serialize like Python `json.dumps(sort_keys=True)` default separators -/// (`", "` and `": "`). -fn python_json(v: &Value) -> Vec { - let compact = serde_json::to_string(v).unwrap(); - // serde_json emits compact `,`/`:`; rewrite to Python's spaced defaults. - // This is a structural transform on the already-sorted value, so the - // bytes match `json.dumps` for the JSON value space Mizan returns. - let spaced = respace(&compact); - spaced.into_bytes() -} - -/// Insert the spaces Python's default `json.dumps` uses after structural -/// `,`/`:` — but only outside string literals. -fn respace(s: &str) -> String { - let mut out = String::with_capacity(s.len() + s.len() / 8); - let mut in_str = false; - let mut escaped = false; - for c in s.chars() { - if in_str { - out.push(c); - if escaped { - escaped = false; - } else if c == '\\' { - escaped = true; - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => { - in_str = true; - out.push(c); - } - ',' => out.push_str(", "), - ':' => out.push_str(": "), - _ => out.push(c), - } - } - out -} - -fn cached_response(body: Vec, cache_status: &str) -> Response { - let mut resp = (StatusCode::OK, body).into_response(); - let h = resp.headers_mut(); - h.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - if !cache_status.is_empty() { - if let Ok(v) = HeaderValue::from_str(cache_status) { - h.insert("X-Mizan-Cache", v); - } - } - resp -} - -/// Coerce string-valued query params into typed JSON via the function's -/// declared input_params. +/// Coerce string-valued query params into typed JSON values using the +/// function's declared input_params. Strings that don't parse stay as +/// strings — the dispatch wrapper will raise ValidationFailed downstream. fn coerce_query_args( fn_spec: &dyn FunctionSpec, params: &BTreeMap, @@ -408,88 +137,26 @@ fn coerce_query_args( if let Some(raw) = params.get(ip.name) { let parsed = match ip.primitive { mizan_core::Primitive::Integer => raw.parse::().ok().map(Value::from), - mizan_core::Primitive::Number => raw - .parse::() - .ok() - .and_then(|v| serde_json::Number::from_f64(v).map(Value::Number)), + mizan_core::Primitive::Number => raw.parse::().ok().and_then(|v| { + serde_json::Number::from_f64(v).map(Value::Number) + }), mizan_core::Primitive::Boolean => raw.parse::().ok().map(Value::from), mizan_core::Primitive::String => Some(Value::from(raw.clone())), }; - out.insert(ip.name.into(), parsed.unwrap_or_else(|| Value::from(raw.clone()))); + if let Some(v) = parsed { + out.insert(ip.name.into(), v); + } else { + out.insert(ip.name.into(), Value::from(raw.clone())); + } } } out } -/// GET /session/ — the AFI-common session-init endpoint, wired at parity with -/// mizan-django and mizan-fastapi. CSRF tokenization is a Django session -/// mechanism; the endpoint here returns a null token and serves as the -/// readiness probe the wire-parity harness uses. +/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint. +/// CSRF is a Django-only concern; the Rust adapter returns a null token so +/// readiness-probe consumers see a well-formed response. pub async fn session_init() -> Response { - no_store(serde_json::json!({ "csrfToken": null })) -} - -/// GET /manifest/ — emit the edge manifest (contexts + render_strategy + -/// mutations) the way `export_edge_manifest` does, so an HTTP deploy can fetch -/// it. Rides the shared `mizan_core::generate_edge_manifest`. -pub async fn edge_manifest(State(state): State>) -> Response { - let manifest = mizan_core::generate_edge_manifest(&state.base_url); - no_store(manifest) -} - -/// GET /psr/:context_name/ — the PSR descriptor for one context: its -/// `render_strategy` (`"psr"` for a static page re-rendered on mutation, or -/// `"dynamic_cached"` for a user-scoped context) plus the page routes Edge -/// re-renders. This is the adapter telling Edge *how* to cache each context — -/// the PSR half of the manifest, addressable per-context. -pub async fn psr_descriptor( - State(state): State>, - Path(context_name): Path, -) -> Result { - let manifest = mizan_core::generate_edge_manifest(&state.base_url); - let ctx = manifest - .get("contexts") - .and_then(|c| c.get(&context_name)) - .ok_or_else(|| { - ApiError(MizanError::NotFound(format!( - "context {context_name:?} not in manifest" - ))) - })?; - let render_strategy = ctx - .get("render_strategy") - .cloned() - .unwrap_or(Value::Null); - let page_routes = ctx - .get("page_routes") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - Ok(no_store(serde_json::json!({ - "context": context_name, - "render_strategy": render_strategy, - "page_routes": page_routes, - }))) -} - -/// GET /shape/:fn_name/ — the typed query projection (Shapes) for a function's -/// output, derived from the registered type graph by `mizan_core::shapes`. -pub async fn shape_projection(Path(fn_name): Path) -> Result { - let proj = shapes::project_function_output(&fn_name).ok_or_else(|| { - ApiError(MizanError::NotFound(format!( - "no shape projection for {fn_name:?}" - ))) - })?; - Ok(no_store(projection_to_json(&proj))) -} - -fn projection_to_json(proj: &shapes::QueryProjection) -> Value { - let mut fields = Vec::new(); - for f in &proj.fields { - match f { - shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())), - shapes::ShapeField::Nested(n, sub) => { - fields.push(serde_json::json!({ n.clone(): projection_to_json(sub) })); - } - } - } - serde_json::json!({ "type": proj.type_name, "fields": fields }) + let body = serde_json::json!({ "csrfToken": null }); + no_store(body) } diff --git a/backends/mizan-rust-axum/src/lib.rs b/backends/mizan-rust-axum/src/lib.rs index 29f2214..df370fd 100644 --- a/backends/mizan-rust-axum/src/lib.rs +++ b/backends/mizan-rust-axum/src/lib.rs @@ -1,80 +1,58 @@ -//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry, -//! riding the shared AFI-common logic (auth/cache/invalidation/SSR/manifest). +//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry. //! //! Usage: //! ```ignore //! use axum::Router; -//! use mizan_axum::{router, MizanState}; +//! use mizan_axum::router; //! //! #[tokio::main] //! async fn main() { -//! let state = MizanState::builder() -//! .app_state(MyState { /* ... */ }) -//! .build(); -//! let app = Router::new().nest("/api/mizan", router(state)); +//! let app = Router::new().nest("/api/mizan", router()); //! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap(); //! axum::serve(listener, app).await.unwrap(); //! } //! ``` //! //! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`): -//! * `GET /session/` — session-init probe (placeholder CSRF token) -//! * `POST /call/` — RPC dispatch (JSON or multipart) + invalidate -//! * `GET /ctx/:name/` — bundled context fetch (origin-cached) -//! * `GET /ws/` — WebSocket RPC transport (`websocket=` fns) -//! * `GET /manifest/` — edge manifest (contexts/render_strategy/mutations) -//! * `GET /psr/:context/` — per-context PSR descriptor (render_strategy) -//! * `GET /shape/:fn/` — typed query projection (Shapes) -//! * `POST /ssr/` — server-side render via the Bun worker -//! * `POST /form/:name/{schema,validate,submit}/` — forms binding +//! * `GET /session/` — session-init probe (placeholder CSRF token) +//! * `POST /call/` — RPC dispatch with invalidate+merge response +//! * `GET /ctx/:name/` — bundled context fetch mod errors; -mod forms; mod handlers; -mod ssr; -mod state; -mod ws; pub use errors::ApiError; -pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse}; -pub use ssr::{ssr_render, SsrRequest}; -pub use state::{AppStateAny, MizanState, MizanStateBuilder}; +pub use handlers::{ + context_fetch, function_call, session_init, AppStateAny, CallBody, CallResponse, +}; use axum::routing::{get, post}; use axum::Router; use std::any::Any; use std::sync::Arc; -/// Build the Mizan router with a fully-configured [`MizanState`] (app state + -/// auth + cache + optional SSR worker). Mount under a prefix: -/// `Router::new().nest("/api/mizan", router(state))`. -pub fn router(state: Arc) -> Router { +/// Build the Mizan router with user-supplied app state. The state is +/// type-erased into an `Arc` and threaded into every +/// dispatch via `RequestHandle`. Handlers downcast to their concrete state +/// type. +/// +/// Mount under a prefix: +/// `Router::new().nest("/api/mizan", router(my_state))`. +pub fn router(state: S) -> Router +where + S: Any + Send + Sync + 'static, +{ + let state: AppStateAny = Arc::new(state); Router::new() .route("/session/", get(handlers::session_init)) .route("/call/", post(handlers::function_call)) .route("/ctx/:context_name/", get(handlers::context_fetch)) - .route("/ws/", get(ws::ws_handler)) - .route("/manifest/", get(handlers::edge_manifest)) - .route("/psr/:context_name/", get(handlers::psr_descriptor)) - .route("/shape/:fn_name/", get(handlers::shape_projection)) - .route("/ssr/", post(ssr::ssr_render)) - .route("/form/:form_name/schema/", post(forms::form_schema)) - .route("/form/:form_name/validate/", post(forms::form_validate)) - .route("/form/:form_name/submit/", post(forms::form_submit)) .with_state(state) } -/// Router variant for the common case of just an app state, no auth/cache. -pub fn router_with_state(app_state: S) -> Router -where - S: Any + Send + Sync + 'static, -{ - router(MizanState::builder().app_state(app_state).build()) -} - -/// Router variant for callers that have no app state to thread — the dispatch -/// path receives a unit-typed handle. Used by the AFI fixture and stateless -/// test apps. +/// Router variant for callers that have no app state to thread — the +/// dispatch path receives a unit-typed handle. Used by the AFI fixture +/// and other stateless test apps. pub fn router_stateless() -> Router { - router(MizanState::builder().build()) + router(()) } diff --git a/backends/mizan-rust-axum/src/ssr.rs b/backends/mizan-rust-axum/src/ssr.rs deleted file mode 100644 index 373869d..0000000 --- a/backends/mizan-rust-axum/src/ssr.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! SSR endpoint — drive the Bun renderer through the shared `mizan_core` -//! `SsrBridge` (same newline-delimited JSON-RPC protocol as the Python -//! `SSRBridge`). The bridge spawns on first render and stays alive. -//! -//! POST /ssr/ { "file": "/abs/Component.tsx", "props": {...} } → { "html": "..." } - -use axum::extract::State; -use axum::response::Response; -use axum::Json; -use mizan_core::MizanError; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::sync::Arc; - -use crate::errors::ApiError; -use crate::state::MizanState; - -#[derive(Deserialize)] -pub struct SsrRequest { - pub file: String, - #[serde(default)] - pub props: Value, -} - -/// POST /ssr/ — render a component file via the Bun SSR worker. -pub async fn ssr_render( - State(state): State>, - Json(req): Json, -) -> Result { - let bridge = state.ssr().ok_or_else(|| { - ApiError(MizanError::NotImplementedYet( - "no SSR worker configured (set MizanState::builder().ssr_worker(...))".into(), - )) - })?; - let props = if req.props.is_null() { - json!({}) - } else { - req.props - }; - let html = bridge - .render(&req.file, props) - .map_err(|e| ApiError(MizanError::InternalError(e.to_string())))?; - - let mut resp = axum::response::IntoResponse::into_response(Json(json!({ "html": html }))); - resp.headers_mut().insert( - axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("no-store"), - ); - Ok(resp) -} diff --git a/backends/mizan-rust-axum/src/state.rs b/backends/mizan-rust-axum/src/state.rs deleted file mode 100644 index 7ac4536..0000000 --- a/backends/mizan-rust-axum/src/state.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Router state — the Mizan config (auth + origin cache) threaded alongside -//! the user's type-erased app state. -//! -//! `app_state` is the consumer's own state, type-erased into `Arc` -//! and handed to every `dispatch()` via `RequestHandle` (handlers downcast to -//! their concrete type — unchanged from the pre-AFI router). `auth` and -//! `cache` are the AFI-common config the handlers read for enforcement and -//! origin caching; an `SsrBridge` is created lazily on the first SSR render. - -use mizan_core::{AuthConfig, CacheOrchestrator, SsrBridge}; -use std::any::Any; -use std::sync::{Arc, OnceLock}; - -pub type AppStateAny = Arc; - -/// The full state every Mizan handler receives. Built via [`MizanState::builder`]. -pub struct MizanState { - /// The consumer's app state, threaded into dispatch via `RequestHandle`. - pub app_state: AppStateAny, - /// JWT/MWT auth config (token → identity resolution + enforcement). - pub auth: AuthConfig, - /// Origin-side HMAC cache orchestrator (disabled by default). - pub cache: CacheOrchestrator, - /// Mizan API mount point, used by the edge-manifest endpoint. - pub base_url: String, - /// Lazily-spawned SSR bridge; configured via the builder's `ssr_worker`. - pub(crate) ssr_worker: Option, - pub(crate) ssr_bridge: OnceLock, -} - -impl MizanState { - pub fn builder() -> MizanStateBuilder { - MizanStateBuilder::default() - } - - /// The SSR bridge, spawned on first use. `None` if no worker was set. - pub fn ssr(&self) -> Option<&SsrBridge> { - let worker = self.ssr_worker.as_ref()?; - Some( - self.ssr_bridge - .get_or_init(|| SsrBridge::bun(worker.clone())), - ) - } -} - -/// Builder for [`MizanState`]. Defaults: unit app state, no auth, cache -/// disabled, `/api/mizan` base URL, no SSR worker. -pub struct MizanStateBuilder { - app_state: AppStateAny, - auth: AuthConfig, - cache: CacheOrchestrator, - base_url: String, - ssr_worker: Option, -} - -impl Default for MizanStateBuilder { - fn default() -> Self { - Self { - app_state: Arc::new(()), - auth: AuthConfig::new(), - cache: CacheOrchestrator::disabled(), - base_url: "/api/mizan".to_string(), - ssr_worker: None, - } - } -} - -impl MizanStateBuilder { - /// Set the consumer's app state (threaded into dispatch). - pub fn app_state(mut self, state: S) -> Self { - self.app_state = Arc::new(state); - self - } - - pub fn auth(mut self, auth: AuthConfig) -> Self { - self.auth = auth; - self - } - - pub fn cache(mut self, cache: CacheOrchestrator) -> Self { - self.cache = cache; - self - } - - pub fn base_url(mut self, base_url: impl Into) -> Self { - self.base_url = base_url.into(); - self - } - - /// Configure the Bun SSR worker path; the bridge spawns on first render. - pub fn ssr_worker(mut self, worker_path: impl Into) -> Self { - self.ssr_worker = Some(worker_path.into()); - self - } - - pub fn build(self) -> Arc { - Arc::new(MizanState { - app_state: self.app_state, - auth: self.auth, - cache: self.cache, - base_url: self.base_url, - ssr_worker: self.ssr_worker, - ssr_bridge: OnceLock::new(), - }) - } -} diff --git a/backends/mizan-rust-axum/src/ws.rs b/backends/mizan-rust-axum/src/ws.rs deleted file mode 100644 index 452708c..0000000 --- a/backends/mizan-rust-axum/src/ws.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! WebSocket RPC transport. `@client(websocket=true)` functions declare -//! `Transport::Websocket` in the IR; this routes a real Axum WebSocket handler -//! that dispatches call/fetch frames through the same `mizan-core` registry -//! the HTTP path uses. A call frame naming a non-websocket function is -//! rejected, so the transport boundary the IR declares is enforced. -//! -//! Frame protocol (text JSON), mirroring the HTTP call/ctx shapes: -//! → {"id": 1, "op": "call", "fn": "name", "args": {...}} -//! → {"id": 2, "op": "fetch", "context": "c", "params": {...}} -//! ← {"id": 1, "result": ..., "invalidate": [...], "merge"?: [...]} -//! ← {"id": 2, "data": {fnName: result, ...}} -//! ← {"id": N, "error": {"code": ..., "message": ...}} - -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::State; -use axum::response::Response; -use futures_util::StreamExt; -use mizan_core::{ - compute_invalidation, compute_merges, lookup_context, lookup_function, AuthRequirement, - FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, Transport, FUNCTIONS, -}; -use serde_json::{json, Map, Value}; -use std::sync::Arc; - -use crate::state::MizanState; - -/// GET /ws/ — upgrade to a Mizan WebSocket RPC connection. -pub async fn ws_handler( - ws: WebSocketUpgrade, - State(state): State>, -) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, state)) -} - -async fn handle_socket(mut socket: WebSocket, state: Arc) { - while let Some(Ok(msg)) = socket.next().await { - let text = match msg { - Message::Text(t) => t, - Message::Close(_) => break, - Message::Ping(_) | Message::Pong(_) | Message::Binary(_) => continue, - }; - let reply = handle_frame(&state, &text).await; - if socket - .send(Message::Text(reply.to_string())) - .await - .is_err() - { - break; - } - } -} - -async fn handle_frame(state: &MizanState, text: &str) -> Value { - let frame: Value = match serde_json::from_str(text) { - Ok(v) => v, - Err(e) => return err_frame(Value::Null, &MizanError::BadRequest(format!("bad frame: {e}"))), - }; - let id = frame.get("id").cloned().unwrap_or(Value::Null); - let op = frame.get("op").and_then(|o| o.as_str()).unwrap_or("call"); - - match op { - "call" => match dispatch_ws_call(state, &frame).await { - Ok(v) => with_id(id, v), - Err(e) => err_frame(id, &e), - }, - "fetch" => match dispatch_ws_fetch(state, &frame).await { - Ok(v) => with_id(id, json!({ "data": v })), - Err(e) => err_frame(id, &e), - }, - other => err_frame(id, &MizanError::BadRequest(format!("unknown op {other:?}"))), - } -} - -async fn dispatch_ws_call(state: &MizanState, frame: &Value) -> Result { - let fn_name = frame - .get("fn") - .and_then(|f| f.as_str()) - .ok_or_else(|| MizanError::BadRequest("missing `fn`".into()))?; - let args = frame - .get("args") - .and_then(|a| a.as_object()) - .cloned() - .unwrap_or_default(); - - let fn_spec = - lookup_function(fn_name).ok_or_else(|| MizanError::NotFound(format!("{fn_name:?}")))?; - if fn_spec.private() { - return Err(MizanError::Forbidden("Function is not client-callable".into())); - } - // The WS transport only carries functions that opted into it. - if !matches!(fn_spec.transport(), Transport::Websocket | Transport::Both) { - return Err(MizanError::BadRequest(format!( - "function {fn_name:?} is not exposed over the WebSocket transport" - ))); - } - enforce_anon_guard(fn_spec)?; - - let req = RequestHandle::from_dyn(state.app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?; - - let targets = compute_invalidation(fn_spec, &args); - let invalidate: Vec = targets.iter().map(InvalidationTarget::to_json).collect(); - let merges = compute_merges(fn_spec, &args, &result); - - let mut out = Map::new(); - out.insert("result".into(), result); - out.insert("invalidate".into(), Value::Array(invalidate)); - if !merges.is_empty() { - out.insert( - "merge".into(), - Value::Array(merges.iter().map(MergeEntry::to_json).collect()), - ); - } - Ok(Value::Object(out)) -} - -async fn dispatch_ws_fetch(state: &MizanState, frame: &Value) -> Result { - let ctx = frame - .get("context") - .and_then(|c| c.as_str()) - .ok_or_else(|| MizanError::BadRequest("missing `context`".into()))?; - if lookup_context(ctx).is_none() { - return Err(MizanError::NotFound(format!("context {ctx:?}"))); - } - let params = frame - .get("params") - .and_then(|p| p.as_object()) - .cloned() - .unwrap_or_default(); - - let members: Vec<&dyn FunctionSpec> = FUNCTIONS - .iter() - .copied() - .filter(|f| f.context() == Some(ctx)) - .collect(); - - let mut bundle = Map::new(); - for fn_spec in &members { - enforce_anon_guard(*fn_spec)?; - let mut args = Map::new(); - for ip in fn_spec.input_params() { - if let Some(v) = params.get(ip.name) { - args.insert(ip.name.into(), v.clone()); - } - } - let req = RequestHandle::from_dyn(state.app_state.as_ref()); - let result = fn_spec.dispatch(req, Value::Object(args)).await?; - bundle.insert(fn_spec.name().to_string(), result); - } - Ok(Value::Object(bundle)) -} - -/// Enforce a function's auth guard for the WS transport. The WS upgrade -/// carries no per-frame identity in this baseline, so a guarded function is -/// rejected over WS — the same enforce-or-reject contract the HTTP path uses, -/// applied with an anonymous identity. -fn enforce_anon_guard(fn_spec: &dyn FunctionSpec) -> Result<(), MizanError> { - let req = AuthRequirement::from_str_opt(fn_spec.auth()); - mizan_core::enforce_auth(None, &req) -} - -fn with_id(id: Value, mut body: Value) -> Value { - if let Some(obj) = body.as_object_mut() { - obj.insert("id".into(), id); - } - body -} - -fn err_frame(id: Value, e: &MizanError) -> Value { - json!({ - "id": id, - "error": { "code": e.code(), "message": e.message() }, - }) -} diff --git a/backends/mizan-rust-axum/tests/behavior.rs b/backends/mizan-rust-axum/tests/behavior.rs deleted file mode 100644 index 8c9e7c5..0000000 --- a/backends/mizan-rust-axum/tests/behavior.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! Runtime behavior tests for the axum adapter — the conformance ceiling that -//! the source-presence probes set the floor for. Each AFI-common HTTP cell is -//! driven end to end through the real router (`tower::ServiceExt::oneshot`, -//! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs -//! against a real bound port. - -use axum::body::Body; -use axum::http::{Request, StatusCode}; -use http_body_util::BodyExt; -use mizan_core as mizan; -use mizan_core::prelude::*; -use mizan_core::{ - AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload, -}; -use mizan_axum::{router, MizanState}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::sync::Arc; -use tower::ServiceExt; - -// ─── Fixture: the functions these tests dispatch ──────────────────────────── - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Profile { - pub user_id: i64, - pub name: String, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Ok { - pub ok: bool, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Secret { - pub flag: String, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct UploadEcho { - pub filename: String, - pub size: i64, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct SchemaOut { - pub fields: Vec, -} - -#[mizan::context("bprofile")] -pub struct BProfileCtx; - -#[mizan::client(context = BProfileCtx)] -pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile { - Profile { - user_id, - name: format!("user-{user_id}"), - } -} - -#[mizan::client(affects = BProfileCtx)] -pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok { - let _ = (user_id, name); - Ok { ok: true } -} - -#[mizan::client(auth = "staff")] -pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret { - Secret { - flag: "top-secret".into(), - } -} - -#[mizan::client(websocket)] -pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok { - let _ = n; - Ok { ok: true } -} - -#[mizan::client] -pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho { - let _ = user_id; - UploadEcho { - filename: avatar.filename.clone().unwrap_or_default(), - size: avatar.size() as i64, - } -} - -#[mizan::client(form_name = "contact", form_role = "submit")] -pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok { - let _ = name; - Ok { ok: true } -} - -#[mizan::client(form_name = "contact", form_role = "schema")] -pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut { - SchemaOut { - fields: vec!["name".into()], - } -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -fn stateless_app() -> axum::Router { - router(MizanState::builder().build()) -} - -async fn body_json(resp: axum::response::Response) -> Value { - let bytes = resp.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&bytes).unwrap() -} - -async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response { - let req = Request::builder() - .method("POST") - .uri("/call/") - .header("content-type", "application/json") - .body(Body::from(json!({"fn": fn_name, "args": args}).to_string())) - .unwrap(); - app.clone().oneshot(req).await.unwrap() -} - -// ─── invalidate_header + invalidate_body + rpc_call ────────────────────────── - -#[tokio::test] -async fn call_emits_invalidate_body_and_header() { - let app = stateless_app(); - let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await; - assert_eq!(resp.status(), StatusCode::OK); - - // The header is co-equal with the body channel: scoped to user_id=7. - let header = resp - .headers() - .get("X-Mizan-Invalidate") - .expect("X-Mizan-Invalidate present") - .to_str() - .unwrap() - .to_string(); - assert_eq!(header, "bprofile;user_id=7"); - assert_eq!( - resp.headers().get("cache-control").unwrap(), - "no-store" - ); - - let body = body_json(resp).await; - assert_eq!(body["result"], json!({"ok": true})); - // Body invalidate entry is the scoped object form. - assert_eq!( - body["invalidate"], - json!([{"context": "bprofile", "params": {"user_id": 7}}]) - ); -} - -// ─── auth_enforcement ──────────────────────────────────────────────────────── - -#[tokio::test] -async fn auth_guard_rejects_anonymous_and_admits_staff() { - // No auth config + a staff-guarded fn → anonymous is rejected 401. - let app = stateless_app(); - let resp = post_call(&app, "b_secret", json!({})).await; - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // With a JWT config + a staff token, the same call is admitted. Mint at - // the real clock so the token is unexpired when the handler verifies it. - let cfg = JwtConfig::new("beh-secret"); - let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix()); - let auth = AuthConfig { - jwt: Some(cfg), - mwt_secret: None, - mwt_audience: "mizan".into(), - }; - let app = router(MizanState::builder().auth(auth).build()); - let req = Request::builder() - .method("POST") - .uri("/call/") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string())) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - assert_eq!(body["result"], json!({"flag": "top-secret"})); -} - -#[tokio::test] -async fn auth_guard_forbids_non_staff_token() { - // A valid but non-staff token → 403 on a staff-guarded fn. - let cfg = JwtConfig::new("beh-secret"); - let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix()); - let auth = AuthConfig { - jwt: Some(cfg), - mwt_secret: None, - mwt_audience: "mizan".into(), - }; - let app = router(MizanState::builder().auth(auth).build()); - let req = Request::builder() - .method("POST") - .uri("/call/") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string())) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn invalid_token_is_rejected_not_downgraded() { - // A present-but-bad bearer rejects (401) even on an unguarded context — - // the INVALID-sentinel contract. - let auth = AuthConfig { - jwt: Some(JwtConfig::new("beh-secret")), - mwt_secret: None, - mwt_audience: "mizan".into(), - }; - let app = router(MizanState::builder().auth(auth).build()); - let req = Request::builder() - .method("GET") - .uri("/ctx/bprofile/?user_id=1") - .header("authorization", "Bearer not-a-real-token") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -// ─── origin_cache ──────────────────────────────────────────────────────────── - -#[tokio::test] -async fn context_fetch_uses_origin_cache() { - let backend: Arc = Arc::new(MemoryCache::new()); - let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into())); - let app = router(MizanState::builder().cache(cache).build()); - - // First fetch: MISS, populates the cache. - let req = Request::builder() - .uri("/ctx/bprofile/?user_id=3") - .body(Body::empty()) - .unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS"); - let first = body_json(resp).await; - assert_eq!(first["b_user_profile"]["user_id"], json!(3)); - - // Second fetch: HIT, served from cache. - let req = Request::builder() - .uri("/ctx/bprofile/?user_id=3") - .body(Body::empty()) - .unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT"); - let second = body_json(resp).await; - assert_eq!(first, second); - - // A mutation scoped to user_id=3 purges that key → next fetch MISSes. - let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await; - let req = Request::builder() - .uri("/ctx/bprofile/?user_id=3") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS"); -} - -// ─── upload ────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn multipart_upload_binds_into_input() { - let app = stateless_app(); - let boundary = "----mizanbeh"; - let file_bytes = b"PNGDATA-0123456789"; - let body = format!( - "--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\ - --{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\ - --{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\ - Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n", - b = boundary, - data = String::from_utf8_lossy(file_bytes), - ); - let req = Request::builder() - .method("POST") - .uri("/call/") - .header( - "content-type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - assert_eq!(body["result"]["filename"], json!("a.png")); - assert_eq!(body["result"]["size"], json!(file_bytes.len())); -} - -// ─── edge_manifest + psr ───────────────────────────────────────────────────── - -#[tokio::test] -async fn manifest_and_psr_descriptor() { - let app = stateless_app(); - - let req = Request::builder() - .uri("/manifest/") - .body(Body::empty()) - .unwrap(); - let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await; - // bprofile is user-scoped (user_id) → dynamic_cached. - assert_eq!( - manifest["contexts"]["bprofile"]["render_strategy"], - json!("dynamic_cached") - ); - assert_eq!( - manifest["mutations"]["b_update_profile"]["affects"], - json!(["bprofile"]) - ); - - // Per-context PSR descriptor. - let req = Request::builder() - .uri("/psr/bprofile/") - .body(Body::empty()) - .unwrap(); - let psr = body_json(app.oneshot(req).await.unwrap()).await; - assert_eq!(psr["render_strategy"], json!("dynamic_cached")); -} - -// ─── shapes ────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn shape_projection_endpoint() { - let app = stateless_app(); - let req = Request::builder() - .uri("/shape/b_user_profile/") - .body(Body::empty()) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - // Output type name is camelCased by the macro (`b_user_profile` → - // `bUserProfile`), suffixed `Output`. - assert_eq!(body["type"], json!("bUserProfileOutput")); - let fields = body["fields"].as_array().unwrap(); - assert!(fields.contains(&json!("user_id"))); - assert!(fields.contains(&json!("name"))); -} - -// ─── forms ─────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn forms_schema_and_submit_routes() { - let app = stateless_app(); - - let req = Request::builder() - .method("POST") - .uri("/form/contact/schema/") - .header("content-type", "application/json") - .body(Body::from("{}")) - .unwrap(); - let resp = app.clone().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - assert_eq!(body["fields"], json!(["name"])); - - let req = Request::builder() - .method("POST") - .uri("/form/contact/submit/") - .header("content-type", "application/json") - .body(Body::from(json!({"name": "Ada"}).to_string())) - .unwrap(); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(body_json(resp).await, json!({"ok": true})); -} - -// ─── websocket ─────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn websocket_transport_dispatches_and_rejects_non_ws_fn() { - use tokio_tungstenite::tungstenite::Message; - - // Bind a real socket — the WS upgrade needs an actual connection. - let app = stateless_app(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let url = format!("ws://{addr}/ws/"); - let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); - - // A websocket-declared fn dispatches. - use futures_util::{SinkExt, StreamExt}; - socket - .send(Message::Text( - json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(), - )) - .await - .unwrap(); - let reply = socket.next().await.unwrap().unwrap(); - let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); - assert_eq!(v["id"], json!(1)); - assert_eq!(v["result"], json!({"ok": true})); - - // A non-websocket fn over WS is rejected (transport boundary enforced). - socket - .send(Message::Text( - json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}}) - .to_string(), - )) - .await - .unwrap(); - let reply = socket.next().await.unwrap().unwrap(); - let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); - assert_eq!(v["id"], json!(2)); - assert!(v["error"]["message"] - .as_str() - .unwrap() - .contains("WebSocket transport")); - - server.abort(); -} diff --git a/backends/mizan-tauri/Cargo.lock b/backends/mizan-tauri/Cargo.lock index 5b3904d..084a2f8 100644 --- a/backends/mizan-tauri/Cargo.lock +++ b/backends/mizan-tauri/Cargo.lock @@ -558,7 +558,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -1229,15 +1228,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -1555,7 +1545,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -1564,15 +1554,37 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1670,9 +1682,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -1776,13 +1788,10 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", - "base64 0.22.1", - "hmac", "linkme", "mizan-macros", "serde", "serde_json", - "sha2", ] [[package]] @@ -1799,12 +1808,10 @@ dependencies = [ name = "mizan-tauri" version = "0.1.0" dependencies = [ - "base64 0.22.1", "mizan-core", "serde", "serde_json", "tauri", - "tokio", ] [[package]] @@ -1835,7 +1842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.1", - "jni-sys", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -1849,7 +1856,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2460,9 +2467,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -2901,12 +2908,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -3359,21 +3360,9 @@ dependencies = [ "mio", "pin-project-lite", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -3796,9 +3785,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -3809,9 +3798,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3819,9 +3808,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3829,9 +3818,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3842,9 +3831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -3898,9 +3887,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/backends/mizan-tauri/Cargo.toml b/backends/mizan-tauri/Cargo.toml index ee25e98..9278ece 100644 --- a/backends/mizan-tauri/Cargo.toml +++ b/backends/mizan-tauri/Cargo.toml @@ -10,8 +10,3 @@ mizan-core = { path = "../../cores/mizan-rust" } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" - -[dev-dependencies] -tauri = { version = "2", features = ["test"] } -tokio = { version = "1", features = ["rt", "macros"] } -base64 = "0.22" diff --git a/backends/mizan-tauri/src/lib.rs b/backends/mizan-tauri/src/lib.rs index 8ee617a..e56c0f2 100644 --- a/backends/mizan-tauri/src/lib.rs +++ b/backends/mizan-tauri/src/lib.rs @@ -1,5 +1,4 @@ -//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC, riding the -//! shared `mizan-core` dispatch/auth/cache/invalidation/shapes logic. +//! Mizan Tauri adapter — typed RPC dispatch over Tauri's IPC. //! //! Ships as a Tauri plugin. The consumer installs it with one line: //! @@ -10,137 +9,79 @@ //! .expect("error while running tauri application"); //! ``` //! -//! The plugin exposes commands reachable from the JS-side -//! `@mizan/tauri-transport`: +//! The plugin exposes a single command `mizan_invoke` (full Tauri name +//! `plugin:mizan|mizan_invoke`). The JS-side `@mizan/tauri-transport` +//! sends call/fetch envelopes to it; the dispatch routes through +//! `mizan-core`'s FUNCTIONS / CONTEXTS registries — the same +//! linkme-backed distributed slices the HTTP adapter (mizan-rust-axum) +//! consumes. There is no per-function tauri::command; the registry IS +//! the dispatch table. //! -//! * `mizan_invoke` — call / fetch / shape / form dispatch (the request/ -//! response surface, mirroring the HTTP adapter's POST /call/ + GET /ctx/). -//! * `mizan_subscribe` — opens an IPC subscription `Channel` for a -//! `#[mizan(websocket)]` function; this is the IPC transport's analogue of -//! the HTTP WebSocket — there are no sockets in a desktop shell, so a -//! Tauri `Channel` carries the push stream instead. -//! -//! Wire envelope (the `mizan_invoke` payload's `envelope` field): +//! Wire envelope: //! //! ```json -//! { "op": "call", "fn": "list_sessions", "args": {}, "token": "..."? } -//! { "op": "fetch", "context": "session", "params": {}, "token": "..."? } -//! { "op": "shape", "fn": "user_profile" } -//! { "op": "form", "form": "contact", "role": "submit", "args": {} } +//! { "op": "call", "fn": "list_sessions", "args": {} } +//! { "op": "fetch", "context": "session", "params": {} } //! ``` //! -//! Response shapes mirror the HTTP adapter: +//! Response shapes mirror POST /call/ and GET /ctx/.../ from +//! mizan-rust-axum: //! -//! * `call` → `{ result, invalidate, merge? }` -//! * `fetch` → `{ : , ... }` (a flat bundle) -//! * `shape` → `{ type, fields }` -//! * `form` → the form function's result +//! * `call` → `{ result, invalidate, merge? }` +//! * `fetch` → `{ : , ... }` (a flat bundle) //! -//! Auth: the envelope's optional `token` carries an MWT (`X-Mizan-Token` -//! equivalent) or a `Bearer `; it is resolved through the shared -//! `authenticate` and enforced against each function's `auth=` requirement. -//! There is no header channel over IPC, so the token rides the envelope. -//! -//! Errors come back as the `Err` variant of the command's `Result`, which -//! Tauri serializes into the JS-side rejection; the TS transport re-wraps it -//! into a `MizanError`. - -mod ssr; - -pub use ssr::{ssr_render, MizanSsr}; +//! Error responses come back as the `Err` variant of the Tauri command's +//! `Result`, which Tauri serializes into the JS-side `Promise.reject`. +//! The TS-side transport re-wraps it into a `MizanError` so consumers +//! see one error surface regardless of transport. use mizan_core::{ - authenticate, compute_invalidation, compute_merges, enforce_auth, lookup_context, - lookup_function, now_unix, shapes, AuthConfig, AuthOutcome, AuthRequirement, CacheOrchestrator, - FunctionSpec, Identity, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, + compute_invalidation, compute_merges, lookup_context, lookup_function, + FunctionSpec, InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use tauri::ipc::Channel; use tauri::{ plugin::{Builder, TauriPlugin}, - Manager, Runtime, + Runtime, }; -/// The Mizan config Tauri manages: auth (token → identity) + the origin cache. -/// The consumer registers it with `app.manage(MizanTauriConfig { .. })`; the -/// dispatch commands read it from managed state. -pub struct MizanTauriConfig { - pub auth: AuthConfig, - pub cache: CacheOrchestrator, -} - -impl Default for MizanTauriConfig { - fn default() -> Self { - Self { - auth: AuthConfig::new(), - cache: CacheOrchestrator::disabled(), - } - } -} - -/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())`. -/// Registers a default (auth-off, cache-disabled) config if the consumer -/// hasn't managed one; commands are reachable as `plugin:mizan|mizan_invoke` -/// and `plugin:mizan|mizan_subscribe`. +/// Build the Mizan Tauri plugin. Install with `.plugin(mizan_tauri::init())` +/// on the `tauri::Builder`. The plugin name is `mizan`; the dispatch +/// command is reachable from JS as `plugin:mizan|mizan_invoke`. pub fn init() -> TauriPlugin { Builder::::new("mizan") - .invoke_handler(tauri::generate_handler![ - mizan_invoke, - mizan_subscribe, - ssr::ssr_render - ]) - .setup(|app, _api| { - if app.try_state::().is_none() { - app.manage(MizanTauriConfig::default()); - } - Ok(()) - }) + .invoke_handler(tauri::generate_handler![mizan_invoke]) .build() } // === Wire envelope === -/// One Mizan request. Tauri's serde deserializer pulls this out of the -/// `envelope` field of the invoke payload. +/// One Mizan request. The JS-side transport sends `{ envelope: ... }`; +/// Tauri's serde deserializer pulls this struct out of the `envelope` +/// field of the invoke payload. #[derive(Debug, Deserialize)] #[serde(tag = "op")] pub enum Envelope { #[serde(rename = "call")] Call { + /// Wire-level function name — registered name on the Rust side. #[serde(rename = "fn")] function_name: String, #[serde(default)] args: Map, - /// Optional auth token (MWT, or `Bearer `) — the IPC analogue of - /// the HTTP `X-Mizan-Token` / `Authorization` headers. - #[serde(default)] - token: Option, }, #[serde(rename = "fetch")] Fetch { context: String, #[serde(default)] params: Map, - #[serde(default)] - token: Option, - }, - #[serde(rename = "shape")] - Shape { - #[serde(rename = "fn")] - function_name: String, - }, - #[serde(rename = "form")] - Form { - form: String, - role: String, - #[serde(default)] - args: Value, }, } /// Error payload returned to the frontend. Mirrors the HTTP adapter's -/// `{"code", "message", "details?"}` shape. +/// `{"code", "message", "details?"}` shape; the TS-side transport reads +/// this and constructs a `MizanError`. #[derive(Debug, Serialize)] pub struct ErrorPayload { pub code: &'static str, @@ -164,336 +105,110 @@ impl From for ErrorPayload { } } -// === Auth === +// === Dispatch === -/// Resolve identity from an envelope `token`. An MWT is tried first (raw -/// token), then a `Bearer `. A present-but-invalid token rejects (the -/// `INVALID`-sentinel contract); absent → anonymous. -fn identity_from_token( - token: Option<&str>, - config: &MizanTauriConfig, -) -> Result, MizanError> { - let (mwt, bearer) = match token { - Some(t) if t.starts_with("Bearer ") => (None, Some(t)), - Some(t) => (Some(t), None), - None => (None, None), - }; - match authenticate(mwt, bearer, &config.auth, now_unix()) { - AuthOutcome::Authenticated(id) => Ok(Some(id)), - AuthOutcome::Anonymous => Ok(None), - AuthOutcome::Invalid => Err(MizanError::Unauthorized("Invalid or expired token".into())), - } -} - -fn guard(fn_spec: &dyn FunctionSpec, identity: Option<&Identity>) -> Result<(), MizanError> { - enforce_auth(identity, &AuthRequirement::from_str_opt(fn_spec.auth())) -} - -// === Dispatch commands === - -/// The single Mizan request/response command. Tauri auto-injects `app`; the -/// body borrows it into a `RequestHandle` so `#[mizan::client]` functions can -/// `req.downcast::()` for managed state or event emission. +/// The single Mizan dispatch command. Registered on the plugin's invoke +/// handler — the consumer never wires it directly. +/// +/// `app: AppHandle` is auto-injected by Tauri; the function body borrows +/// it into a `RequestHandle` so `#[mizan::client]` functions can +/// `req.downcast::()` for app-managed state or event +/// emission. Stateless functions ignore the handle. #[tauri::command] async fn mizan_invoke( app: tauri::AppHandle, envelope: Envelope, ) -> Result { - dispatch(&app, envelope).await.map_err(ErrorPayload::from) -} - -/// Dispatch one Mizan [`Envelope`] against an `AppHandle`, returning the JSON -/// response (or a `MizanError`). This is the programmatic entry point the -/// `mizan_invoke` IPC command wraps — exposed so embedders (and behavior -/// tests) can drive the Mizan protocol without the IPC serialization layer. -pub async fn dispatch( - app: &tauri::AppHandle, - envelope: Envelope, -) -> Result { - // Read the managed config (lifetime-bound to `app`, which outlives this - // dispatch); fall back to a default if none was registered. The `State` - // guard is held across the awaits below. - let managed = app.try_state::(); - let default; - let cfg: &MizanTauriConfig = match managed.as_ref() { - Some(state) => state.inner(), - None => { - default = MizanTauriConfig::default(); - &default - } - }; match envelope { Envelope::Call { function_name, args, - token, - } => handle_call(app, cfg, &function_name, args, token.as_deref()).await, - Envelope::Fetch { - context, - params, - token, - } => handle_fetch(app, cfg, &context, params, token.as_deref()).await, - Envelope::Shape { function_name } => handle_shape(&function_name), - Envelope::Form { form, role, args } => handle_form(app, &form, &role, args).await, + } => handle_call(&app, &function_name, args).await, + Envelope::Fetch { context, params } => handle_fetch(&app, &context, params).await, } } async fn handle_call( app: &tauri::AppHandle, - cfg: &MizanTauriConfig, fn_name: &str, - mut args: Map, - token: Option<&str>, -) -> Result { - let identity = identity_from_token(token, cfg)?; - - let fn_spec = lookup_function(fn_name) - .ok_or_else(|| MizanError::NotFound(format!("function {fn_name:?} not registered")))?; - if fn_spec.private() { - return Err(MizanError::Forbidden("Function is not client-callable".into())); - } - guard(fn_spec, identity.as_ref())?; - - // Bind any file parts the envelope carries into the call args (see - // `bind_uploads`). - bind_uploads(fn_spec, &mut args)?; + args: Map, +) -> Result { + let fn_spec = lookup_function(fn_name).ok_or_else(|| { + ErrorPayload::from(MizanError::NotFound(format!( + "function {fn_name:?} not registered" + ))) + })?; let req = RequestHandle::new(app); - let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?; + let result = fn_spec + .dispatch(req, Value::Object(args.clone())) + .await + .map_err(ErrorPayload::from)?; - let targets = compute_invalidation(fn_spec, &args); - let invalidate: Vec = targets.iter().map(InvalidationTarget::to_json).collect(); + let invalidate: Vec = compute_invalidation(fn_spec, &args) + .iter() + .map(InvalidationTarget::to_json) + .collect(); let merges = compute_merges(fn_spec, &args, &result); + let merge_payload: Option> = if merges.is_empty() { + None + } else { + Some(merges.iter().map(MergeEntry::to_json).collect()) + }; - // Purge the origin cache for everything this mutation invalidated. - if !targets.is_empty() { - let uid = identity.as_ref().map(|i| i.user_id.clone()); - cfg.cache.purge(&targets, uid.as_deref()); - } - - let mut payload = json!({ "result": result, "invalidate": invalidate }); - if !merges.is_empty() { - payload.as_object_mut().unwrap().insert( - "merge".into(), - Value::Array(merges.iter().map(MergeEntry::to_json).collect()), - ); + let mut payload = json!({ + "result": result, + "invalidate": invalidate, + }); + if let Some(merge) = merge_payload { + payload + .as_object_mut() + .expect("payload is a JSON object") + .insert("merge".into(), Value::Array(merge)); } Ok(payload) } async fn handle_fetch( app: &tauri::AppHandle, - cfg: &MizanTauriConfig, context_name: &str, params: Map, - token: Option<&str>, -) -> Result { - let identity = identity_from_token(token, cfg)?; - +) -> Result { if lookup_context(context_name).is_none() { - return Err(MizanError::NotFound(format!( + return Err(ErrorPayload::from(MizanError::NotFound(format!( "context {context_name:?} not registered" - ))); + )))); } + let members: Vec<&dyn FunctionSpec> = FUNCTIONS .iter() .copied() .filter(|f| f.context() == Some(context_name)) .collect(); if members.is_empty() { - return Err(MizanError::NotFound(format!( + return Err(ErrorPayload::from(MizanError::NotFound(format!( "context {context_name:?} has no registered members" - ))); - } - - // Origin cache: a desktop shell still benefits from memoizing a context - // bundle by (context, params, user). Key the params as JSON values. - let cache_params: std::collections::BTreeMap = params - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let uid = identity.as_ref().map(|i| i.user_id.clone()); - - if let Some(cached) = cfg - .cache - .get(context_name, &cache_params, uid.as_deref(), 0) - { - if let Ok(v) = serde_json::from_slice::(&cached) { - return Ok(v); - } + )))); } let mut bundled = Map::new(); for fn_spec in &members { - guard(*fn_spec, identity.as_ref())?; let args = filter_args(*fn_spec, ¶ms); let req = RequestHandle::new(app); - let result = fn_spec.dispatch(req, Value::Object(args)).await?; + let result = fn_spec + .dispatch(req, Value::Object(args)) + .await + .map_err(ErrorPayload::from)?; bundled.insert(fn_spec.name().to_string(), result); } - let body = Value::Object(bundled); - if cfg.cache.enabled() { - let bytes = serde_json::to_vec(&body).unwrap(); - cfg.cache - .put(context_name, &cache_params, bytes, uid.as_deref(), 0); - } - Ok(body) + Ok(Value::Object(bundled)) } -/// `shape` op — the typed query projection for a function's output, derived by -/// the shared `mizan_core::shapes` (the IPC adapter's Shapes binding). -fn handle_shape(fn_name: &str) -> Result { - let proj = shapes::project_function_output(fn_name) - .ok_or_else(|| MizanError::NotFound(format!("no shape projection for {fn_name:?}")))?; - Ok(projection_to_json(&proj)) -} - -fn projection_to_json(proj: &shapes::QueryProjection) -> Value { - let mut fields = Vec::new(); - for f in &proj.fields { - match f { - shapes::ShapeField::Leaf(n) => fields.push(Value::String(n.clone())), - shapes::ShapeField::Nested(n, sub) => { - fields.push(json!({ n.clone(): projection_to_json(sub) })); - } - } - } - json!({ "type": proj.type_name, "fields": fields }) -} - -/// `form` op — dispatch a form's schema/validate/submit function (the IPC -/// Forms binding). `form_validate` / `form_submit` map to the registered -/// function whose `(form_name, form_role)` matches. -async fn handle_form( - app: &tauri::AppHandle, - form_name: &str, - role: &str, - args: Value, -) -> Result { - match role { - "schema" => form_schema(app, form_name).await, - "validate" => form_validate(app, form_name, args).await, - "submit" => form_submit(app, form_name, args).await, - other => Err(MizanError::BadRequest(format!( - "unknown form role {other:?} (expected schema|validate|submit)" - ))), - } -} - -fn lookup_form_fn(form_name: &str, role: &str) -> Option<&'static dyn FunctionSpec> { - FUNCTIONS - .iter() - .copied() - .find(|f| f.is_form() && f.form_name() == Some(form_name) && f.form_role() == Some(role)) -} - -async fn dispatch_form_role( - app: &tauri::AppHandle, - form_name: &str, - role: &str, - args: Value, -) -> Result { - let fn_spec = lookup_form_fn(form_name, role) - .ok_or_else(|| MizanError::NotFound(format!("no form {form_name:?} with role {role:?}")))?; - let args_value = match args { - Value::Object(_) | Value::Null => args, - other => json!({ "data": other }), - }; - let req = RequestHandle::new(app); - fn_spec.dispatch(req, args_value).await -} - -async fn form_schema( - app: &tauri::AppHandle, - form_name: &str, -) -> Result { - dispatch_form_role(app, form_name, "schema", Value::Null).await -} - -async fn form_validate( - app: &tauri::AppHandle, - form_name: &str, - args: Value, -) -> Result { - dispatch_form_role(app, form_name, "validate", args).await -} - -async fn form_submit( - app: &tauri::AppHandle, - form_name: &str, - args: Value, -) -> Result { - dispatch_form_role(app, form_name, "submit", args).await -} - -// === WebSocket-equivalent: IPC subscription channel === - -/// One frame pushed down a subscription `Channel`. Mirrors the WS reply shape. -#[derive(Clone, Serialize)] -pub struct SubscriptionFrame { - pub result: Value, - pub invalidate: Vec, -} - -/// `mizan_subscribe` — open an IPC subscription for a `#[mizan(websocket)]` -/// function. A desktop shell has no WebSocket; a Tauri `Channel` carries -/// the push stream instead — the IPC transport's co-equal of the HTTP -/// WebSocket. The initial dispatch result is emitted immediately on the -/// channel; subsequent server-side pushes use the same `on_event` channel. -#[tauri::command] -async fn mizan_subscribe( - app: tauri::AppHandle, - function_name: String, - args: Map, - on_event: Channel, -) -> Result<(), ErrorPayload> { - subscribe(&app, &function_name, args, on_event) - .await - .map_err(ErrorPayload::from) -} - -/// Open a subscription for a `#[mizan(websocket)]` function, pushing frames on -/// `on_event`. The programmatic entry point the `mizan_subscribe` IPC command -/// wraps — exposed for embedders and behavior tests. -pub async fn subscribe( - app: &tauri::AppHandle, - function_name: &str, - args: Map, - on_event: Channel, -) -> Result<(), MizanError> { - let fn_spec = lookup_function(function_name) - .ok_or_else(|| MizanError::NotFound(format!("function {function_name:?} not registered")))?; - if fn_spec.private() { - return Err(MizanError::Forbidden("Function is not client-callable".into())); - } - // Only `#[mizan(websocket)]` functions are exposed over the subscription - // channel — the same transport boundary the HTTP WebSocket enforces. - if !matches!( - fn_spec.transport(), - mizan_core::Transport::Websocket | mizan_core::Transport::Both - ) { - return Err(MizanError::BadRequest(format!( - "function {function_name:?} is not exposed over the subscription transport" - ))); - } - - let req = RequestHandle::new(app); - let result = fn_spec.dispatch(req, Value::Object(args.clone())).await?; - let invalidate = compute_invalidation(fn_spec, &args) - .iter() - .map(InvalidationTarget::to_json) - .collect(); - - on_event - .send(SubscriptionFrame { result, invalidate }) - .map_err(|e| MizanError::InternalError(format!("subscription channel send failed: {e}")))?; - Ok(()) -} - -// === Helpers === - -/// Filter the envelope's params down to keys this function declares as input. +/// Filter the envelope's params down to keys this function declares as +/// input. The HTTP/axum adapter coerces string-typed query params to +/// JSON primitives in the equivalent step; the Tauri arg channel already +/// carries typed JSON, so the filter is sufficient on its own. fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map) -> Map { let mut out = Map::new(); for ip in fn_spec.input_params() { @@ -503,45 +218,3 @@ fn filter_args(fn_spec: &dyn FunctionSpec, params: &Map) -> Map | [, ...] }`) is merged into the args under -/// each field name, mirroring how the HTTP adapter binds multipart parts. It -/// also validates that anything presenting as a file carries `data_b64`, -/// surfacing a clear error before the typed `Upload` deserialize runs. -fn bind_uploads( - fn_spec: &dyn FunctionSpec, - args: &mut Map, -) -> Result<(), MizanError> { - if let Some(Value::Object(files)) = args.remove("_files") { - for (field, parts) in files { - args.insert(field, parts); - } - } - - // The set of param names this function declares — only validate args that - // could land in a typed field. - let declared: std::collections::HashSet<&str> = - fn_spec.input_params().iter().map(|p| p.name).collect(); - for (name, value) in args.iter() { - if !declared.contains(name.as_str()) { - continue; - } - if let Value::Object(obj) = value { - let looks_like_file = - obj.contains_key("filename") || obj.contains_key("content_type"); - if looks_like_file && !obj.contains_key("data_b64") { - return Err(MizanError::BadRequest(format!( - "upload field {name:?} is missing `data_b64` (the base64 file bytes)" - ))); - } - } - } - Ok(()) -} diff --git a/backends/mizan-tauri/src/ssr.rs b/backends/mizan-tauri/src/ssr.rs deleted file mode 100644 index 161285d..0000000 --- a/backends/mizan-tauri/src/ssr.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! SSR over the IPC transport — drive the Bun renderer through the shared -//! `mizan_core::SsrBridge` (the same newline-delimited JSON-RPC protocol the -//! Django/FastAPI/axum adapters use). A desktop shell renders React the same -//! way the server does: spawn the Bun worker once, drive `renderToString` -//! through it, keep it alive. -//! -//! Exposed as a Tauri command + a managed `MizanSsr` holding the bridge: -//! -//! invoke('plugin:mizan|ssr_render', { file: '/abs/X.tsx', props: {...} }) -//! → { html: "
...
" } - -use mizan_core::SsrBridge; -use serde::Serialize; -use serde_json::Value; -use std::sync::Arc; -use tauri::{Manager, Runtime}; - -use crate::ErrorPayload; - -/// Managed SSR state — holds the persistent Bun bridge. Register it with -/// `app.manage(MizanSsr::new("path/to/worker.tsx"))` to enable `ssr_render`. -pub struct MizanSsr { - bridge: Arc, -} - -impl MizanSsr { - /// Build an SSR state that launches `bun run ` on first render. - pub fn new(worker_path: impl Into) -> Self { - Self { - bridge: Arc::new(SsrBridge::bun(worker_path)), - } - } - - /// The shared `mizan_core` SSR bridge backing this state — the persistent - /// Bun subprocess that runs `renderToString` over JSON-RPC. Exposed so a - /// consumer can render directly (e.g. PSR re-render on mutation) without - /// going through the `ssr_render` IPC command. - pub fn ssr_bridge(&self) -> &SsrBridge { - &self.bridge - } -} - -#[derive(Serialize)] -pub struct SsrResult { - pub html: String, -} - -/// `ssr_render` — render a component file to HTML via the Bun SSR worker. -/// Requires a managed `MizanSsr` (else returns a NOT_IMPLEMENTED error). -#[tauri::command] -pub async fn ssr_render( - app: tauri::AppHandle, - file: String, - props: Option, -) -> Result { - let state = app.try_state::().ok_or_else(|| { - ErrorPayload::from(mizan_core::MizanError::NotImplementedYet( - "no SSR worker configured (app.manage(MizanSsr::new(...)))".into(), - )) - })?; - let bridge = state.bridge.clone(); - let props = props.unwrap_or_else(|| serde_json::json!({})); - let html = bridge - .render(&file, props) - .map_err(|e| ErrorPayload::from(mizan_core::MizanError::InternalError(e.to_string())))?; - Ok(SsrResult { html }) -} diff --git a/backends/mizan-tauri/tests/behavior.rs b/backends/mizan-tauri/tests/behavior.rs deleted file mode 100644 index d1fa040..0000000 --- a/backends/mizan-tauri/tests/behavior.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling -//! over the source-presence probes. Each IPC-applicable cell is driven through -//! the real dispatch path against a mock Tauri `AppHandle` -//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel -//! frames. The IPC serialization boundary is exercised by Tauri's own -//! `get_ipc_response` machinery in integration; here we drive `dispatch` / -//! `subscribe` (the programmatic entry points the commands wrap) so the -//! protocol logic — auth, cache, upload binding, shapes, forms, subscription — -//! is asserted directly. - -use mizan_core as mizan; -use mizan_core::prelude::*; -use mizan_core::{ - AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload, -}; -use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use std::sync::{Arc, Mutex}; -use tauri::ipc::Channel; -use tauri::test::mock_app; -use tauri::{AppHandle, Manager}; - -// ─── Fixture functions (auto-registered via linkme at link time) ──────────── - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct TProfile { - pub user_id: i64, - pub name: String, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct TOk { - pub ok: bool, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct TSecret { - pub flag: String, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct TUploadEcho { - pub filename: String, - pub size: i64, -} - -#[mizan::context("tprofile")] -pub struct TProfileCtx; - -#[mizan::client(context = TProfileCtx)] -pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile { - TProfile { - user_id, - name: format!("user-{user_id}"), - } -} - -#[mizan::client(affects = TProfileCtx)] -pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk { - let _ = (user_id, name); - TOk { ok: true } -} - -#[mizan::client(auth = "staff")] -pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret { - TSecret { - flag: "ipc-secret".into(), - } -} - -#[mizan::client(websocket)] -pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk { - let _ = room; - TOk { ok: true } -} - -#[mizan::client] -pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho { - let _ = user_id; - TUploadEcho { - filename: avatar.filename.clone().unwrap_or_default(), - size: avatar.size() as i64, - } -} - -#[mizan::client(form_name = "tcontact", form_role = "submit")] -pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk { - let _ = name; - TOk { ok: true } -} - -// ─── Harness ──────────────────────────────────────────────────────────────── - -/// Build a mock app with the given Mizan config managed. -fn app_with(config: MizanTauriConfig) -> AppHandle { - let app = mock_app(); - let handle = app.handle().clone(); - handle.manage(config); - // Leak the app so its `AppHandle` stays valid for the test body; the - // process tears down at test end. - std::mem::forget(app); - handle -} - -fn rt() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() -} - -// ─── rpc_call + invalidate_body ────────────────────────────────────────────── - -#[test] -fn call_returns_result_and_invalidate() { - let handle = app_with(MizanTauriConfig::default()); - rt().block_on(async { - let env = Envelope::Call { - function_name: "t_update_profile".into(), - args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]), - token: None, - }; - let resp = dispatch(&handle, env).await.unwrap(); - assert_eq!(resp["result"], json!({"ok": true})); - // IPC carries invalidation in the envelope (no header channel). - assert_eq!( - resp["invalidate"], - json!([{"context": "tprofile", "params": {"user_id": 7}}]) - ); - }); -} - -// ─── auth_enforcement ──────────────────────────────────────────────────────── - -#[test] -fn auth_guard_over_ipc() { - rt().block_on(async { - // No auth config → anonymous → staff-guarded fn rejected. - let handle = app_with(MizanTauriConfig::default()); - let err = dispatch( - &handle, - Envelope::Call { - function_name: "t_secret".into(), - args: Map::new(), - token: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, mizan::MizanError::Unauthorized(_))); - - // Staff JWT on the envelope token → admitted. - let cfg = JwtConfig::new("ipc-secret"); - let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix()); - let config = MizanTauriConfig { - auth: AuthConfig { - jwt: Some(cfg), - mwt_secret: None, - mwt_audience: "mizan".into(), - }, - cache: CacheOrchestrator::disabled(), - }; - let handle = app_with(config); - let resp = dispatch( - &handle, - Envelope::Call { - function_name: "t_secret".into(), - args: Map::new(), - token: Some(format!("Bearer {token}")), - }, - ) - .await - .unwrap(); - assert_eq!(resp["result"]["flag"], json!("ipc-secret")); - }); -} - -#[test] -fn invalid_token_rejected_over_ipc() { - rt().block_on(async { - let config = MizanTauriConfig { - auth: AuthConfig { - jwt: Some(JwtConfig::new("ipc-secret")), - mwt_secret: None, - mwt_audience: "mizan".into(), - }, - cache: CacheOrchestrator::disabled(), - }; - let handle = app_with(config); - let err = dispatch( - &handle, - Envelope::Fetch { - context: "tprofile".into(), - params: obj(&[("user_id", json!(1))]), - token: Some("Bearer garbage".into()), - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, mizan::MizanError::Unauthorized(_))); - }); -} - -// ─── origin_cache ──────────────────────────────────────────────────────────── - -#[test] -fn fetch_uses_origin_cache() { - rt().block_on(async { - let backend: Arc = Arc::new(MemoryCache::new()); - let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into())); - let config = MizanTauriConfig { - auth: AuthConfig::new(), - cache, - }; - let handle = app_with(config); - - let fetch = || Envelope::Fetch { - context: "tprofile".into(), - params: obj(&[("user_id", json!(3))]), - token: None, - }; - - let first = dispatch(&handle, fetch()).await.unwrap(); - assert_eq!(first["t_user_profile"]["user_id"], json!(3)); - - // The cache now holds the bundle — confirm a key exists under the - // context prefix (proves the put happened). - let key = mizan::derive_cache_key( - "ipc-cache-secret", - "tprofile", - &std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]), - None, - 0, - ); - assert!(backend.get(&key).is_some(), "fetch populated the origin cache"); - - // Second fetch returns the same bundle (served from cache). - let second = dispatch(&handle, fetch()).await.unwrap(); - assert_eq!(first, second); - - // A scoped mutation purges the key. - let _ = dispatch( - &handle, - Envelope::Call { - function_name: "t_update_profile".into(), - args: obj(&[("user_id", json!(3)), ("name", json!("New"))]), - token: None, - }, - ) - .await - .unwrap(); - assert!(backend.get(&key).is_none(), "mutation purged the cache key"); - }); -} - -// ─── upload ────────────────────────────────────────────────────────────────── - -#[test] -fn upload_binds_from_envelope() { - use base64::engine::general_purpose::STANDARD; - use base64::Engine; - rt().block_on(async { - let handle = app_with(MizanTauriConfig::default()); - let data = b"IPC-FILE-BYTES"; - let file = json!({ - "filename": "a.png", - "content_type": "image/png", - "data_b64": STANDARD.encode(data), - }); - let resp = dispatch( - &handle, - Envelope::Call { - function_name: "t_set_avatar".into(), - args: obj(&[("user_id", json!(9)), ("avatar", file)]), - token: None, - }, - ) - .await - .unwrap(); - assert_eq!(resp["result"]["filename"], json!("a.png")); - assert_eq!(resp["result"]["size"], json!(data.len())); - }); -} - -// ─── shapes ────────────────────────────────────────────────────────────────── - -#[test] -fn shape_op_projects_output() { - rt().block_on(async { - let handle = app_with(MizanTauriConfig::default()); - let resp = dispatch( - &handle, - Envelope::Shape { - function_name: "t_user_profile".into(), - }, - ) - .await - .unwrap(); - assert_eq!(resp["type"], json!("tUserProfileOutput")); - let fields = resp["fields"].as_array().unwrap(); - assert!(fields.contains(&json!("user_id"))); - assert!(fields.contains(&json!("name"))); - }); -} - -// ─── forms ─────────────────────────────────────────────────────────────────── - -#[test] -fn form_submit_op() { - rt().block_on(async { - let handle = app_with(MizanTauriConfig::default()); - let resp = dispatch( - &handle, - Envelope::Form { - form: "tcontact".into(), - role: "submit".into(), - args: json!({"name": "Ada"}), - }, - ) - .await - .unwrap(); - assert_eq!(resp, json!({"ok": true})); - }); -} - -// ─── websocket-equivalent: subscription channel ────────────────────────────── - -#[test] -fn subscription_pushes_frame_and_rejects_non_ws_fn() { - rt().block_on(async { - let handle = app_with(MizanTauriConfig::default()); - - // A websocket-declared fn pushes a frame on the channel. - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink = captured.clone(); - let channel: Channel = Channel::new(move |body| { - // The channel serializes the SubscriptionFrame to JSON; read it - // back as a generic Value. - let v: Value = body.deserialize().unwrap_or(Value::Null); - sink.lock().unwrap().push(v); - Ok(()) - }); - subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel) - .await - .unwrap(); - - let frames = captured.lock().unwrap(); - assert_eq!(frames.len(), 1, "subscription pushed exactly one frame"); - assert_eq!(frames[0]["result"], json!({"ok": true})); - - // A non-websocket fn over the subscription transport is rejected. - let reject_channel: Channel = Channel::new(|_| Ok(())); - let err = subscribe( - &handle, - "t_user_profile", - obj(&[("user_id", json!(1))]), - reject_channel, - ) - .await - .unwrap_err(); - assert!(err.message().contains("subscription transport")); - }); -} - -// ─── helpers ────────────────────────────────────────────────────────────────── - -fn obj(pairs: &[(&str, Value)]) -> Map { - pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() -} diff --git a/backends/mizan-ts/bun.lock b/backends/mizan-ts/bun.lock index 1286743..5b0c283 100644 --- a/backends/mizan-ts/bun.lock +++ b/backends/mizan-ts/bun.lock @@ -5,31 +5,15 @@ "": { "name": "@mizan/ts", "devDependencies": { - "@types/react": "^19", - "@types/react-dom": "^19", "bun-types": "latest", - "react": "^19", - "react-dom": "^19", }, }, }, "packages": { "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], - "@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/backends/mizan-ts/package.json b/backends/mizan-ts/package.json index 6d1f29b..59bac8c 100644 --- a/backends/mizan-ts/package.json +++ b/backends/mizan-ts/package.json @@ -8,11 +8,7 @@ "test": "bun test" }, "devDependencies": { - "@types/react": "^19", - "@types/react-dom": "^19", - "bun-types": "latest", - "react": "^19", - "react-dom": "^19" + "bun-types": "latest" }, "license": "Elastic-2.0" } diff --git a/backends/mizan-ts/src/decorator.ts b/backends/mizan-ts/src/decorator.ts index 133d11d..34b3a06 100644 --- a/backends/mizan-ts/src/decorator.ts +++ b/backends/mizan-ts/src/decorator.ts @@ -13,7 +13,7 @@ * } */ -import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement, type AffectsTarget } from './types' +import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types' import { register } from './registry' function resolveContext(ctx: ReactContext | string | undefined): string | undefined { @@ -21,25 +21,6 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi return ctx } -function normalizeMerge(merge: ClientOptions['merge']): string[] | undefined { - if (!merge) return undefined - const items = Array.isArray(merge) ? merge : [merge] - return items.map((m: AffectsTarget) => (m instanceof ReactContext ? m.name : m)) -} - -/** - * Normalize the public auth option into the stored requirement. - * Mirrors Python: undefined→undefined, true→'required', callable→callable, - * '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( affects: ClientOptions['affects'], ): RegistryEntry['affects'] | undefined { @@ -71,36 +52,6 @@ function extractParams(fn: Function): ParamDef[] { }) } -function buildEntry(options: ClientOptions, name: string, fn: Function): RegistryEntry { - const context = resolveContext(options.context) - const affects = normalizeAffects(options.affects) - - if (context && affects) { - throw new Error('context and affects are mutually exclusive') - } - - return { - name, - fn: fn as any, - context, - affects, - merge: normalizeMerge(options.merge), - params: extractParams(fn), - private: options.private ?? false, - viewPath: false, - route: options.route, - methods: options.methods, - auth: normalizeAuth(options.auth), - websocket: options.websocket, - rev: options.rev, - cache: options.cache, - ir: options.ir, - form: options.form, - formName: options.formName, - formRole: options.formRole, - } -} - /** * Function wrapper — registers a standalone function. * @@ -121,19 +72,69 @@ export function client Promise>( */ export function client(options: ClientOptions): MethodDecorator -export function client(optionsOrFn: ClientOptions, fn?: Function): any { +export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function): any { // Function wrapper form: client(options, fn) if (fn && typeof fn === 'function') { const options = optionsOrFn as ClientOptions + const context = resolveContext(options.context) + const affects = normalizeAffects(options.affects) + + if (context && affects) { + throw new Error('context and affects are mutually exclusive') + } + const name = fn.name || 'anonymous' - register(buildEntry(options, name, fn)) + const params = extractParams(fn) + const isView = false // Determined at call time for function wrappers + + const entry: RegistryEntry = { + name, + fn: fn as any, + context, + affects, + params, + private: options.private ?? false, + viewPath: isView, + route: options.route, + methods: options.methods, + auth: options.auth, + rev: options.rev, + cache: options.cache, + } + + register(entry) return fn } // Decorator form: @client(options) const options = optionsOrFn as ClientOptions return function (_target: any, propertyKey: string, descriptor: PropertyDescriptor) { - register(buildEntry(options, propertyKey, descriptor.value)) + const originalMethod = descriptor.value + const context = resolveContext(options.context) + const affects = normalizeAffects(options.affects) + + if (context && affects) { + throw new Error('context and affects are mutually exclusive') + } + + const params = extractParams(originalMethod) + + const entry: RegistryEntry = { + name: propertyKey, + fn: originalMethod, + context, + affects, + params, + private: options.private ?? false, + viewPath: false, + route: options.route, + methods: options.methods, + auth: options.auth, + rev: options.rev, + cache: options.cache, + } + + register(entry) return descriptor } } diff --git a/backends/mizan-ts/src/dispatch.ts b/backends/mizan-ts/src/dispatch.ts index d94e098..5159769 100644 --- a/backends/mizan-ts/src/dispatch.ts +++ b/backends/mizan-ts/src/dispatch.ts @@ -8,9 +8,6 @@ import { getFunction, getContextGroups } from './registry' import { resolveInvalidation, formatInvalidateHeader } from './invalidation' import { getCache, cacheGet, cachePut, cachePurge } from './cache' -import { ANONYMOUS, type Identity } from './identity' -import type { AuthRequirement } from './types' -import { UploadedFile, bindUploads } from './upload' let _cacheSecret: string | null = null @@ -25,54 +22,6 @@ export interface MizanResponse { headers: Record } -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/ * @@ -81,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse { export async function handleContextFetch( contextName: string, params: Record, - identity: Identity = ANONYMOUS, ): Promise { const groups = getContextGroups() const fnNames = groups[contextName] @@ -94,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) let effectiveRev = 0 for (const fnName of fnNames) { @@ -187,15 +126,13 @@ export async function handleContextFetch( } /** - * Handle POST /api/mizan/call/ — JSON body form. + * Handle POST /api/mizan/call/ * - * Dispatches to a named function. Returns result + invalidation. The multipart - * form (`handleMultipartCall`) binds file parts first, then routes here. + * Dispatches to a named function. Returns result + invalidation. */ export async function handleMutationCall( fnName: string, args: Record, - identity: Identity = ANONYMOUS, ): Promise { const entry = getFunction(fnName) @@ -216,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 { const argValues = entry.params.map(p => args[p.name]) const result = await entry.fn(...argValues) @@ -274,63 +207,3 @@ export async function handleMutationCall( } } } - -function badRequest(message: string): MizanResponse { - return { - status: 400, - body: { error: true, code: 'BAD_REQUEST', message }, - headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' }, - } -} - -/** - * Handle POST /api/mizan/call/ — multipart/form-data form. - * - * Mirrors FastAPI's `_parse_call`: `fn` names the function, the non-file fields - * arrive in a JSON `args` part, and each file part binds into the function's - * Upload-typed inputs (by field name) with declared `File(...)` constraints - * enforced. After binding, execution is identical to the JSON path. - * - * A part is treated as a file when it is a `Blob`/`File` (Web `FormData`); other - * parts that share an Upload field name are accepted too. - */ -export async function handleMultipartCall( - form: FormData, - identity: Identity = ANONYMOUS, -): Promise { - const fnRaw = form.get('fn') - if (typeof fnRaw !== 'string' || !fnRaw) return badRequest("Missing 'fn' field") - const fnName = fnRaw - - const argsRaw = form.get('args') - let args: Record - try { - args = typeof argsRaw === 'string' && argsRaw ? JSON.parse(argsRaw) : {} - } catch { - return badRequest("Invalid JSON in 'args' field") - } - if (typeof args !== 'object' || args === null) return badRequest("'args' must be a JSON object") - - const entry = getFunction(fnName) - if (entry) { - // Collect file parts by field name into UploadedFile buckets. - const files = new Map() - for (const key of new Set(form.keys())) { - if (key === 'fn' || key === 'args') continue - const bucket: UploadedFile[] = [] - for (const part of form.getAll(key)) { - if (part instanceof Blob) { - const data = new Uint8Array(await part.arrayBuffer()) - const filename = part instanceof File ? part.name : null - bucket.push(new UploadedFile(filename, part.type || null, data)) - } - } - if (bucket.length > 0) files.set(key, bucket) - } - - const err = bindUploads(entry, args, files) - if (err !== null) return badRequest(err) - } - - return handleMutationCall(fnName, args, identity) -} diff --git a/backends/mizan-ts/src/forms.ts b/backends/mizan-ts/src/forms.ts deleted file mode 100644 index c83472f..0000000 --- a/backends/mizan-ts/src/forms.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Forms — schema / validate / submit, AFI-common. - * - * The binding is per-framework (Django Forms on Django; the project's form - * layer elsewhere). The TypeScript binding registers the same three `@client` - * functions `create_form_functions` registers, carrying the same - * `{ form, form_name, form_role }` meta the IR reads — `-schema`, - * `-validate`, and (when a submit handler is given) `-submit`. - * - * schema → { fields: FieldSchema[] } — field definitions - * validate → { valid: boolean, errors: {field: [..]} } — per-field validation - * submit → the handler's return value — validate-then-handle - * - * A `FormField` declares its type/required/label and an optional `validate` - * predicate; `validateForm` runs every field's validator over the submitted - * data, mirroring Django's `form.is_valid()` / `form.errors`. - */ - -import { client } from './decorator' -import type { FormRole } from './types' - -export interface FormField { - name: string - type?: string - required?: boolean - label?: string - helpText?: string - choices?: Array<{ value: string; label: string }> - initial?: unknown - /** - * Field validator. Return an error message (or array of messages) to - * reject, or null/undefined to accept. Required-ness is enforced before - * the validator runs. - */ - validate?: (value: unknown, data: Record) => string | string[] | null | undefined -} - -export interface FormDefinition { - fields: FormField[] -} - -export interface FieldSchema { - name: string - type: string - required: boolean - label: string - helpText: string - choices: Array<{ value: string; label: string }> | null - initial: unknown -} - -export interface FormSchemaOutput { - fields: FieldSchema[] -} - -export interface FormValidationOutput { - valid: boolean - errors: Record -} - -function titleize(name: string): string { - return name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) -} - -/** Build the field-definition schema for a form. Mirrors `build_form_schema`. */ -export function formSchema(def: FormDefinition): FormSchemaOutput { - return { - fields: def.fields.map((f) => ({ - name: f.name, - type: f.type ?? 'text', - required: f.required ?? true, - label: f.label ?? titleize(f.name), - helpText: f.helpText ?? '', - choices: f.choices ?? null, - initial: f.initial ?? null, - })), - } -} - -/** - * Validate submitted `data` against a form. Required fields missing/empty and - * any field whose `validate` returns a message produce per-field errors. - * Mirrors Django's `is_valid()` → `{valid, errors}`. - */ -export function validateForm(def: FormDefinition, data: Record): FormValidationOutput { - const errors: Record = {} - - for (const field of def.fields) { - const value = data[field.name] - const missing = value === undefined || value === null || value === '' - if ((field.required ?? true) && missing) { - errors[field.name] = ['This field is required.'] - continue - } - if (missing) continue - const result = field.validate?.(value, data) - if (result !== null && result !== undefined) { - errors[field.name] = Array.isArray(result) ? result : [result] - } - } - - return { valid: Object.keys(errors).length === 0, errors } -} - -/** A submit handler runs after validation passes. */ -export type FormSubmitHandler = (data: Record) => unknown | Promise - -export interface FormRegistration { - schema: string - validate: string - submit?: string -} - -/** - * Register a form's schema / validate / submit functions with the registry. - * - * Equivalent to Python's `register_form`: three `@client` functions named - * `-schema`, `-validate`, `-submit`, each carrying - * `{ form, formName, formRole }` so the IR emits `is-form`/`form-name`/ - * `form-role`. `submit` is registered only when a handler is supplied. - * - * Returns the registered wire names. - */ -export function registerForm( - def: FormDefinition, - name: string, - options: { submit?: FormSubmitHandler } = {}, -): FormRegistration { - const role = (r: FormRole) => ({ form: true, formName: name, formRole: r }) - - const schemaName = `${name}-schema` - const validateName = `${name}-validate` - const submitName = `${name}-submit` - - // schema — returns the field definitions. - const schemaFn = async function () { - return formSchema(def) - } - Object.defineProperty(schemaFn, 'name', { value: schemaName }) - client(role('schema'), schemaFn) - - // validate — runs per-field validation over the submitted data. - const validateFn = async function (data: Record) { - return validateForm(def, data) - } - Object.defineProperty(validateFn, 'name', { value: validateName }) - client(role('validate'), validateFn) - - const registration: FormRegistration = { schema: schemaName, validate: validateName } - - // submit — validate, then hand off. Registered only with a handler. - if (options.submit) { - const handler = options.submit - const submitFn = async function (data: Record) { - const validation = validateForm(def, data) - if (!validation.valid) { - return { ok: false, errors: validation.errors } - } - const result = await handler(data) - return { ok: true, result } - } - Object.defineProperty(submitFn, 'name', { value: submitName }) - client(role('submit'), submitFn) - registration.submit = submitName - } - - return registration -} diff --git a/backends/mizan-ts/src/identity.ts b/backends/mizan-ts/src/identity.ts deleted file mode 100644 index b5dac16..0000000 --- a/backends/mizan-ts/src/identity.ts +++ /dev/null @@ -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 diff --git a/backends/mizan-ts/src/index.ts b/backends/mizan-ts/src/index.ts index ed29850..bdd1eee 100644 --- a/backends/mizan-ts/src/index.ts +++ b/backends/mizan-ts/src/index.ts @@ -1,63 +1,17 @@ export { ReactContext } from './types' -export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement, FormRole } from './types' - -export { ANONYMOUS } from './identity' -export type { Identity, AuthPredicate } from './identity' - -export { - decodeMwt, - decodeJwtBearer, - identityFromMwt, - signHs256, - signMwt, - mintMwt, - computePermissionKey, - signJwt, - createAccessToken, - createRefreshToken, - mintJwt, -} from './token' -export type { MwtPayload, MintUser, JwtConfig, JwtMintClaims, JwtTokenPair } from './token' +export type { ClientOptions, EdgeManifest, RegistryEntry } from './types' export { client } from './decorator' export { register, getFunction, getAllFunctions, getContextGroups, clearRegistry } from './registry' -export { handleContextFetch, handleMutationCall, handleMultipartCall } from './dispatch' +export { handleContextFetch, handleMutationCall } from './dispatch' export type { MizanResponse } from './dispatch' -export { UploadedFile, parseSize, validateUpload, bindUploads, uploadFields } from './upload' -export type { File as UploadFile } from './upload' - export { resolveInvalidation, formatInvalidateHeader } from './invalidation' export { generateManifest } from './manifest' -export { handleSessionInit, sessionInitRoute, SESSION_INIT_PATH, SESSION_INIT_METHOD } from './session' - -export { SSRBridge } from './ssr' -export type { SSRBridgeOptions, RenderResult } from './ssr' - -export { handleWebSocketMessage, serveWebSocket } from './websocket' -export type { MizanWsFrame, MizanWsReply, WebSocketLike } from './websocket' - -export { buildIr, snakeToCamel } from './ir' -export type { IrSchema, TypeShape, NamedType, StructField, Primitive, DefaultValue } from './ir' - -export { Shape, project, projectRecord } from './shapes' -export type { QueryProjection } from './shapes' - -export { registerForm, formSchema, validateForm } from './forms' -export type { - FormField, - FormDefinition, - FieldSchema, - FormSchemaOutput, - FormValidationOutput, - FormSubmitHandler, - FormRegistration, -} from './forms' - export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache' export type { CacheBackend } from './cache' export { setCacheSecret } from './dispatch' diff --git a/backends/mizan-ts/src/ir/build.ts b/backends/mizan-ts/src/ir/build.ts deleted file mode 100644 index 3e39a15..0000000 --- a/backends/mizan-ts/src/ir/build.ts +++ /dev/null @@ -1,409 +0,0 @@ -/** - * KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`. - * - * The Python emitter is the spec; this is a second implementation under the - * same contract. `buildIr()` walks the registry, resolves the canonical named - * types each function references (`_collect_named_types`), and emits KDL the - * Rust codegen consumes. Any divergence is a bug here, not a contract change — - * `tests/ir.test.ts` pins byte-equality against the live Python `build_ir()`. - */ - -import { getAllFunctions, getContextGroups, getFunction } from '../registry' -import type { RegistryEntry } from '../types' -import type { DefaultValue, NamedType, Primitive, StructField, TypeShape } from './types' - -const INDENT = ' ' - -// ─── KDL value formatting (mirrors ir.py `_kdl_*`) ──────────────────────────── - -function kdlString(s: string): string { - const escaped = s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - return `"${escaped}"` -} - -function kdlBool(b: boolean): string { - return b ? '#true' : '#false' -} - -function kdlDefault(v: DefaultValue): string { - switch (v.kind) { - case 'null': - return '#null' - case 'boolean': - return kdlBool(v.value) - case 'integer': - return String(v.value) - case 'number': - // Match Python's repr(float): whole-number floats render as "1.0". - return Number.isInteger(v.value) ? `${v.value}.0` : String(v.value) - case 'string': - return kdlString(v.value) - } -} - -/** snake_case → camelCase. Matches ir.py `_snake_to_camel`. */ -export function snakeToCamel(name: string): string { - const parts = name.replace(/\./g, '_').replace(/-/g, '_').split('_') - return parts[0] + parts.slice(1).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join('') -} - -function primitiveName(p: Primitive): string { - return p -} - -// ─── Emitter ────────────────────────────────────────────────────────────── - -class Emitter { - lines: string[] = [] - - private prefix(indent: number): string { - return INDENT.repeat(indent) - } - - leaf(indent: number, ...parts: string[]): void { - this.lines.push(this.prefix(indent) + parts.join(' ')) - } - - open(indent: number, ...parts: string[]): void { - this.lines.push(this.prefix(indent) + parts.join(' ') + ' {') - } - - close(indent: number): void { - this.lines.push(this.prefix(indent) + '}') - } - - blank(): void { - this.lines.push('') - } - - emitTypeChild(indent: number, shape: TypeShape): void { - switch (shape.kind) { - case 'primitive': - this.leaf(indent, 'primitive', kdlString(primitiveName(shape.primitive))) - return - case 'ref': - this.leaf(indent, 'ref', kdlString(shape.name)) - return - case 'list': - this.open(indent, 'list') - this.emitTypeChild(indent + 1, shape.inner) - this.close(indent) - return - case 'optional': - this.open(indent, 'optional') - this.emitTypeChild(indent + 1, shape.inner) - this.close(indent) - return - case 'enum': - this.leaf(indent, 'enum', ...shape.variants.map(kdlString)) - return - case 'union': - this.open(indent, 'union') - for (const b of shape.branches) this.emitTypeChild(indent + 1, b) - this.close(indent) - return - case 'upload': - this.emitUpload(indent, shape) - return - } - } - - private emitUpload(indent: number, shape: Extract): void { - const props: string[] = [] - if (shape.maxSize !== undefined) props.push(`max-size=${shape.maxSize}`) - if (shape.contentTypes && shape.contentTypes.length > 0) { - this.open(indent, 'upload', ...props) - for (const ct of shape.contentTypes) this.leaf(indent + 1, 'content-type', kdlString(ct)) - this.close(indent) - } else { - this.leaf(indent, 'upload', ...props) - } - } - - emitNamedType(indent: number, name: string, body: NamedType): void { - this.open(indent, 'type', kdlString(name)) - if (body.kind === 'struct') { - this.open(indent + 1, 'struct') - for (const field of body.fields) this.emitStructField(indent + 2, field) - this.close(indent + 1) - } else if (body.kind === 'alias') { - this.open(indent + 1, 'alias') - this.emitTypeChild(indent + 2, body.inner) - this.close(indent + 1) - } else { - this.leaf(indent + 1, 'enum', ...body.variants.map(kdlString)) - } - this.close(indent) - } - - emitStructField(indent: number, field: StructField): void { - const header: string[] = ['field', kdlString(field.name)] - if (!field.required) { - header.push(`required=${kdlBool(false)}`) - if (field.default !== undefined) header.push(`default=${kdlDefault(field.default)}`) - } - this.open(indent, ...header) - this.emitTypeChild(indent + 1, field.shape) - this.close(indent) - } - - intoString(): string { - const lines = [...this.lines] - while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() - return lines.join('\n') + '\n' - } -} - -// ─── Named-type collection (mirrors ir.py `_collect_named_types`) ───────────── - -/** Strip Optional[T] → [inner, isOptional]. */ -function stripOptional(shape: TypeShape): [TypeShape, boolean] { - if (shape.kind === 'optional') return [shape.inner, true] - return [shape, false] -} - -/** list element type, or null. */ -function listElement(shape: TypeShape): TypeShape | null { - if (shape.kind === 'list') return shape.inner - return null -} - -/** All ref names reachable inside a shape. */ -function refsIn(shape: TypeShape): string[] { - switch (shape.kind) { - case 'ref': - return [shape.name] - case 'list': - case 'optional': - return refsIn(shape.inner) - case 'union': - return shape.branches.flatMap(refsIn) - default: - return [] - } -} - -/** All ref names a NamedType body references. */ -function refsInBody(body: NamedType): string[] { - if (body.kind === 'struct') return body.fields.flatMap(f => refsIn(f.shape)) - if (body.kind === 'alias') return refsIn(body.inner) - return [] -} - -interface FnTypeInfo { - schema: import('./types').IrSchema - camel: string -} - -/** - * First pass: collect every named type the IR's `function` section references, - * keyed by emitted name. Two kinds, exactly as `_collect_named_types`: - * - structs visited anywhere in input/output traversal (under their ref name, - * and under the canonical `Input` / `Output` rename) - * - output wrapper aliases (`Output = list[T]` / primitive / renamed - * model) so the consumer has one named type to reference. - */ -function collectNamedTypes(fns: Map): Record { - const seen: Record = {} - - function visitModel(name: string, types: Record): void { - if (name in seen) return - const body = types[name] - if (body === undefined) { - throw new Error( - `IR schema references type "${name}" but no definition was provided in the function's \`types\`.`, - ) - } - seen[name] = body - for (const ref of refsInBody(body)) visitModel(ref, types) - } - - function visitShape(shape: TypeShape, types: Record): void { - for (const ref of refsIn(shape)) visitModel(ref, types) - } - - for (const { schema, camel } of fns.values()) { - const types = schema.types ?? {} - - // Input — named `Input`, emitted as a struct. - if (schema.input && schema.input.length > 0) { - const inputName = `${camel}Input` - if (!(inputName in seen)) seen[inputName] = { kind: 'struct', fields: schema.input } - // Visit nested refs in the input fields. - for (const field of schema.input) visitShape(field.shape, types) - } - - // Output. - if (schema.output === undefined) continue - const outputName = `${camel}Output` - const [inner] = stripOptional(schema.output) - const elem = listElement(inner) - - if (elem !== null) { - // list[T] (possibly Optional) — list alias. Visit element type. - visitShape(schema.output, types) - if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output } - } else if (inner.kind === 'ref') { - // or Optional[] — emit the model under the canonical - // output name (rename). Python renames the Pydantic model to - // `Output`; we emit the referenced struct under that name. - const refName = inner.name - const body = types[refName] - if (body === undefined) { - throw new Error( - `IR schema output references type "${refName}" but no definition was provided in the function's \`types\`.`, - ) - } - if (body.kind === 'struct') { - // Emit the struct under the canonical output name (the rename), - // and visit its nested refs. - if (!(outputName in seen)) { - seen[outputName] = body - for (const ref of refsInBody(body)) visitModel(ref, types) - } - } else { - // Non-struct named type referenced as output — emit under its - // own name plus a canonical alias. - visitModel(refName, types) - if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output } - } - } else { - // Primitive-wrapped output (`result: int`) — alias. - if (!(outputName in seen)) seen[outputName] = { kind: 'alias', inner: schema.output } - } - } - - return seen -} - -// ─── Function / context emission ────────────────────────────────────────── - -function resolveOutput(entry: RegistryEntry): { name: string; nullable: boolean } { - const camel = snakeToCamel(entry.name) - const canonical = `${camel}Output` - const schema = entry.ir - if (!schema || schema.output === undefined) return { name: canonical, nullable: false } - const [, nullable] = stripOptional(schema.output) - return { name: canonical, nullable } -} - -function emitFunction(em: Emitter, entry: RegistryEntry): void { - const camel = snakeToCamel(entry.name) - const schema = entry.ir ?? {} - const hasInput = !!(schema.input && schema.input.length > 0) - const { name: outputName, nullable } = resolveOutput(entry) - - em.open(0, 'function', kdlString(entry.name)) - em.leaf(1, 'camel', kdlString(camel)) - em.leaf(1, 'has-input', kdlBool(hasInput)) - if (hasInput) em.leaf(1, 'input', kdlString(`${camel}Input`)) - em.leaf(1, 'output', kdlString(outputName)) - if (nullable) em.leaf(1, 'output-nullable', kdlBool(true)) - em.leaf(1, 'transport', kdlString(entry.websocket ? 'websocket' : 'http')) - if (entry.context) em.leaf(1, 'context', kdlString(entry.context)) - // Only context-typed affects make it into the KDL (matches ir.py). - for (const a of entry.affects ?? []) { - if (a.type === 'context') em.leaf(1, 'affects', kdlString(a.name)) - } - for (const m of entry.merge ?? []) em.leaf(1, 'merge', kdlString(m)) - if (entry.form) { - em.leaf(1, 'is-form', kdlBool(true)) - if (entry.formName) em.leaf(1, 'form-name', kdlString(entry.formName)) - if (entry.formRole) em.leaf(1, 'form-role', kdlString(entry.formRole)) - } - em.close(0) -} - -function annotationToPrimitive(shape: TypeShape | undefined): Primitive { - if (shape === undefined) return 'string' - const [inner] = stripOptional(shape) - if (inner.kind === 'primitive') return inner.primitive - return 'string' -} - -function emitContext(em: Emitter, ctxName: string, fnNames: string[]): void { - // Collect param info across every function in the context. - interface Slot { - type: Primitive - sharedBy: string[] - } - const paramInfo = new Map() - for (const fnName of fnNames) { - const entry = getFunction(fnName) - if (!entry) continue - const input = entry.ir?.input - if (!input || input.length === 0) continue - for (const field of input) { - let slot = paramInfo.get(field.name) - if (!slot) { - slot = { type: 'string', sharedBy: [] } - paramInfo.set(field.name, slot) - } - slot.type = annotationToPrimitive(field.shape) - slot.sharedBy.push(fnName) - } - } - - em.open(0, 'context', kdlString(ctxName)) - // Members alphabetical — canonical order. - for (const fnName of [...fnNames].sort()) em.leaf(1, 'function', kdlString(fnName)) - for (const paramName of [...paramInfo.keys()].sort()) { - const slot = paramInfo.get(paramName)! - const required = slot.sharedBy.length === fnNames.length - em.open(1, 'param', kdlString(paramName)) - em.leaf(2, 'type', kdlString(slot.type)) - em.leaf(2, 'required', kdlBool(required)) - for (const sharer of [...slot.sharedBy].sort()) em.leaf(2, 'shared-by', kdlString(sharer)) - em.close(1) - } - em.close(0) -} - -// ─── Top-level builder ────────────────────────────────────────────────────── - -/** - * Build the Mizan IR (KDL) for every registered function. Byte-equivalent to - * the Python `build_ir()` against the same registry. - * - * `private` and view-path functions are excluded from the function section, - * matching ir.py. - */ -export function buildIr(): string { - const functions = getAllFunctions() - const contextGroups = getContextGroups() - - // Functions contributing to the type/function sections (skip private + view). - const typeFns = new Map() - const emitFns: RegistryEntry[] = [] - for (const [name, entry] of functions) { - if (entry.private || entry.viewPath) continue - typeFns.set(name, { schema: entry.ir ?? {}, camel: snakeToCamel(name) }) - emitFns.push(entry) - } - - const namedTypes = collectNamedTypes(typeFns) - - const em = new Emitter() - - // Types — alphabetical by name (canonical IR ordering). - const typeNames = Object.keys(namedTypes).sort() - for (const typeName of typeNames) em.emitNamedType(0, typeName, namedTypes[typeName]) - if (typeNames.length > 0) em.blank() - - // Functions — alphabetical by wire name. - emitFns.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) - for (const entry of emitFns) emitFunction(em, entry) - if (emitFns.length > 0) em.blank() - - // Contexts — alphabetical by name. - const ctxNames = Object.keys(contextGroups).sort() - for (const ctxName of ctxNames) emitContext(em, ctxName, contextGroups[ctxName]) - if (ctxNames.length > 0) em.blank() - - return em.intoString() -} diff --git a/backends/mizan-ts/src/ir/index.ts b/backends/mizan-ts/src/ir/index.ts deleted file mode 100644 index 8eccf90..0000000 --- a/backends/mizan-ts/src/ir/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Mizan IR (KDL) — the codegen contract. - * - * `buildIr()` emits KDL byte-identical to the Python `build_ir()` against the - * same registry. This is what lets a TypeScript backend feed - * `protocol/mizan-codegen`. - */ - -export { buildIr, snakeToCamel } from './build' -export type { - Primitive, - TypeShape, - DefaultValue, - StructField, - NamedType, - IrSchema, -} from './types' diff --git a/backends/mizan-ts/src/ir/types.ts b/backends/mizan-ts/src/ir/types.ts deleted file mode 100644 index 0df5295..0000000 --- a/backends/mizan-ts/src/ir/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` and - * `cores/mizan-rust/src/ir.rs` 1:1. - * - * The IR is the contract. Backends emit it; the codegen consumes it. The - * TypeScript side produces byte-equivalent KDL to the Python emitter against - * the same function registry. - * - * TypeScript has no Pydantic to introspect, so the `@client` decorator carries - * an explicit IR type schema (input fields + output shape). That schema is the - * binding: a TS backend declares its IR types, and `buildIr()` emits the KDL - * the codegen reads — exactly as the Rust adapter declares typed `StructField` - * / `TypeShape` registrations. - */ - -export type Primitive = 'integer' | 'number' | 'boolean' | 'string' - -/** - * An in-place type shape — referenced from struct fields, function - * inputs/outputs, and alias bodies. - */ -export type TypeShape = - | { kind: 'primitive'; primitive: Primitive } - | { kind: 'ref'; name: string } - | { kind: 'list'; inner: TypeShape } - | { kind: 'optional'; inner: TypeShape } - | { kind: 'enum'; variants: string[] } - | { kind: 'union'; branches: TypeShape[] } - | { kind: 'upload'; maxSize?: number; contentTypes?: string[] } - -export type DefaultValue = - | { kind: 'integer'; value: number } - | { kind: 'number'; value: number } - | { kind: 'boolean'; value: boolean } - | { kind: 'string'; value: string } - | { kind: 'null' } - -export interface StructField { - name: string - required: boolean - default?: DefaultValue - shape: TypeShape -} - -/** A named type that appears in the IR's `type "" { ... }` section. */ -export type NamedType = - | { kind: 'struct'; fields: StructField[] } - | { kind: 'alias'; inner: TypeShape } - | { kind: 'enum'; variants: string[] } - -/** - * The IR type schema a `@client` function carries. - * - * `input` is the ordered list of input fields (already excluding the implicit - * request/identity arg). When absent or empty, the function `has-input #false`. - * - * `output` is the function's return shape: a `ref` to a named struct, a `list`, - * an `optional`, or a `primitive`. The emitter derives the canonical - * `Input` / `Output` names and the struct-vs-alias split exactly - * as `_collect_named_types` does. - * - * `types` resolves every `ref` used in `input`/`output` (and transitively) to - * its `NamedType` definition — Python gets this from Pydantic model - * introspection; TS declares it explicitly. - */ -export interface IrSchema { - input?: StructField[] - output?: TypeShape - types?: Record -} diff --git a/backends/mizan-ts/src/session.ts b/backends/mizan-ts/src/session.ts deleted file mode 100644 index 5fe0ff0..0000000 --- a/backends/mizan-ts/src/session.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Session / CSRF init endpoint — the AFI-common `GET /api/mizan/session/`. - * - * Wired at parity with mizan-django / mizan-fastapi / mizan-rust-axum. The CSRF - * *token* is a Django session mechanism with no TypeScript-runtime equivalent, - * so this returns a null token by default; the endpoint itself is the owed AFI - * surface, and a host that mints CSRF tokens can pass one in. A SPA client uses - * the response as its session-readiness signal. - */ - -import type { MizanResponse } from './dispatch' - -/** - * Canonical mount path for the session-init endpoint, relative to the Mizan - * mount (`/api/mizan`). A router adapter binds `handleSessionInit` here — the - * same `/session/` route Django (`path("session/")`), FastAPI - * (`@router.get("/session/")`), and Axum register. - */ -export const SESSION_INIT_PATH = '/session/' - -/** HTTP method for the session-init route. */ -export const SESSION_INIT_METHOD = 'GET' - -/** - * Build the session-init response. Returns `{ csrfToken }` with `no-store`. - * `csrfToken` defaults to null (no Django-style session); a host with its own - * CSRF mechanism passes the token to embed. - */ -export function handleSessionInit(csrfToken: string | null = null): MizanResponse { - return { - status: 200, - body: { csrfToken }, - headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' }, - } -} - -/** - * Route descriptor for the session-init endpoint — what a router adapter - * registers: `GET /session/` → `handleSessionInit`. Mirrors the - * `path("session/", session_init_view)` URL entry the Python adapters declare. - */ -export const sessionInitRoute = { - path: SESSION_INIT_PATH, - method: SESSION_INIT_METHOD, - handler: () => handleSessionInit(), -} as const diff --git a/backends/mizan-ts/src/shapes.ts b/backends/mizan-ts/src/shapes.ts deleted file mode 100644 index 091ab86..0000000 --- a/backends/mizan-ts/src/shapes.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Shapes — typed query projection. - * - * AFI-common capability; the binding is per-ORM. Django's binding is - * django-readers (select named fields + nested relations from a QuerySet in - * one query). The TypeScript binding is the same shape over the data source a - * TS backend already has: a `QueryProjection` declares the fields and nested - * relations to keep, and `project()` produces records carrying *only* those — - * the over-fetch-elimination the Shapes capability exists for, expressed - * against plain records rather than a Django QuerySet. - * - * A projection composes: a relation is itself a `QueryProjection`, so nested - * shapes prune recursively (mirrors `Shape._spec` / `_build_pair`). - */ - -/** A declarative projection: scalar fields plus nested relation projections. */ -export interface QueryProjection { - /** Scalar field names to keep on each record. */ - fields: string[] - /** Nested relations to keep, each projected by its own `QueryProjection`. */ - relations?: Record -} - -type Record_ = Record - -function projectOne(record: Record_, projection: QueryProjection): Record_ { - const out: Record_ = {} - for (const f of projection.fields) { - if (f in record) out[f] = record[f] - } - for (const [name, child] of Object.entries(projection.relations ?? {})) { - const value = record[name] - if (value === undefined || value === null) { - out[name] = value - } else if (Array.isArray(value)) { - out[name] = value.map((v) => projectOne(v, child)) - } else { - out[name] = projectOne(value, child) - } - } - return out -} - -/** - * Project a list of records through a `QueryProjection`, keeping only the - * declared fields + nested relations. Each output record carries nothing the - * projection didn't name — the typed-projection guarantee. - */ -export function project(records: Record_[], projection: QueryProjection): Record_[] { - return records.map((r) => projectOne(r, projection)) -} - -/** Project a single record. */ -export function projectRecord(record: Record_, projection: QueryProjection): Record_ { - return projectOne(record, projection) -} - -/** - * A reusable Shape: binds a `QueryProjection` to a name so a `@client` context - * function can `Shape.query(source)` and return uniformly-projected records. - * The per-ORM source differs; the projection contract does not. - */ -export class Shape { - constructor( - public readonly name: string, - public readonly projection: QueryProjection, - ) {} - - /** Project a record source through this shape's projection. */ - query(source: Record_[]): Record_[] { - return project(source, this.projection) - } - - /** Project a single record. */ - one(record: Record_): Record_ { - return projectRecord(record, this.projection) - } -} diff --git a/backends/mizan-ts/src/ssr.ts b/backends/mizan-ts/src/ssr.ts deleted file mode 100644 index cb91c66..0000000 --- a/backends/mizan-ts/src/ssr.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * SSR Bridge — manages a persistent Bun subprocess for React server-rendering. - * - * TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire - * protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with - * message-id correlation so concurrent renders don't cross. - * - * → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } } - * ← { "id": 1, "html": "
...
" } - * ← { "id": 1, "error": "..." } (on failure) - * - * The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file - * and calls `renderToString` — no registry. It announces readiness with - * `{ "id": 0, "ready": true }`; the bridge waits for that before accepting - * renders, and restarts the worker if it exits. - */ - -import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' - -export interface SSRBridgeOptions { - /** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */ - worker: string - /** Per-render + startup timeout, seconds. Default 5. */ - timeout?: number - /** Runtime to launch the worker. Default 'bun'. */ - runtime?: string - /** - * Args passed to the runtime before the worker path. Default `['run']` - * (the Bun/`bun run ` convention). Set `[]` for a runtime like - * `node` that takes the script path directly. - */ - runtimeArgs?: string[] -} - -export interface RenderResult { - html: string -} - -interface Pending { - resolve: (msg: any) => void - reject: (err: Error) => void - timer: ReturnType -} - -export class SSRBridge { - private readonly worker: string - private readonly timeoutMs: number - private readonly runtime: string - private readonly runtimeArgs: string[] - - private proc: ChildProcessWithoutNullStreams | null = null - private counter = 0 - private buffer = '' - private readonly pending = new Map() - private readyPromise: Promise | null = null - private readyResolve: (() => void) | null = null - private readyReject: ((err: Error) => void) | null = null - - constructor(options: SSRBridgeOptions) { - this.worker = options.worker - this.timeoutMs = (options.timeout ?? 5) * 1000 - this.runtime = options.runtime ?? 'bun' - this.runtimeArgs = options.runtimeArgs ?? ['run'] - } - - private ensureRunning(): Promise { - if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) { - return this.readyPromise - } - - let settled = false - this.readyPromise = new Promise((resolve, reject) => { - this.readyResolve = () => { - if (!settled) { - settled = true - resolve() - } - } - this.readyReject = (err) => { - if (!settled) { - settled = true - reject(err) - } - } - }) - - const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], { - stdio: ['pipe', 'pipe', 'pipe'], - }) - this.proc = proc - - proc.stdout.setEncoding('utf-8') - proc.stdout.on('data', (chunk: string) => this.onStdout(chunk)) - // Only react to THIS proc's exit — a stale exit event (from a worker we - // already replaced) must not null out the freshly-spawned one. - proc.on('exit', () => this.onExit(proc)) - proc.on('error', (err) => { - this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`)) - }) - - const startTimer = setTimeout(() => { - this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`)) - this.shutdown() - }, this.timeoutMs) - - // Clear the start timer once ready settles (either way). - this.readyPromise.then( - () => clearTimeout(startTimer), - () => clearTimeout(startTimer), - ) - - return this.readyPromise - } - - private onStdout(chunk: string): void { - this.buffer += chunk - let nl: number - while ((nl = this.buffer.indexOf('\n')) !== -1) { - const line = this.buffer.slice(0, nl).trim() - this.buffer = this.buffer.slice(nl + 1) - if (!line) continue - let msg: any - try { - msg = JSON.parse(line) - } catch { - continue // malformed line — ignore, matches the Python reader - } - this.onMessage(msg) - } - } - - private onMessage(msg: any): void { - // Ready signal (id=0). - if (msg.id === 0 && msg.ready) { - this.readyResolve?.() - return - } - const id = msg.id - if (typeof id === 'number' && this.pending.has(id)) { - const p = this.pending.get(id)! - this.pending.delete(id) - clearTimeout(p.timer) - p.resolve(msg) - } - } - - private onExit(proc: ChildProcessWithoutNullStreams): void { - // Ignore exit events from a worker we've already replaced. - if (this.proc !== null && this.proc !== proc) return - - // Fail any in-flight requests; the next call re-spawns a fresh worker. - const err = new Error('SSR worker exited') - for (const [, p] of this.pending) { - clearTimeout(p.timer) - p.reject(err) - } - this.pending.clear() - this.readyReject?.(err) - this.proc = null - this.readyPromise = null - } - - private request(method: string, params: Record): Promise { - const id = ++this.counter - const frame = JSON.stringify({ id, method, params }) + '\n' - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id) - reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`)) - }, this.timeoutMs) - this.pending.set(id, { resolve, reject, timer }) - - try { - this.proc!.stdin.write(frame) - } catch (e: any) { - this.pending.delete(id) - clearTimeout(timer) - reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`)) - } - }) - } - - /** Render a React component file to HTML. Spawns the worker on first use. */ - async render(file: string, props: Record = {}): Promise { - await this.ensureRunning() - const msg = await this.request('render', { file, props }) - if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`) - return { html: msg.html } - } - - /** Health check — resolves true when the worker answers a ping. */ - async ping(): Promise { - await this.ensureRunning() - const msg = await this.request('ping', {}) - return msg.pong === true - } - - /** Stop the Bun subprocess. */ - shutdown(): void { - if (this.proc !== null) { - try { - this.proc.stdin.end() - } catch { - /* already closed */ - } - try { - this.proc.kill() - } catch { - /* already gone */ - } - this.proc = null - this.readyPromise = null - } - } -} diff --git a/backends/mizan-ts/src/token.ts b/backends/mizan-ts/src/token.ts deleted file mode 100644 index e69d099..0000000 --- a/backends/mizan-ts/src/token.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * MWT / JWT mint + decode — HS256, cross-language parity with - * `cores/mizan-python/src/mizan_core/mwt.py` and `.../auth/jwt.py`. - * - * Decode returns null on ANY failure (bad signature, expired, future nbf, - * wrong aud, malformed) and never throws. Mint is byte-identical to PyJWT's - * `jwt.encode(...)`: the JOSE header is serialized with sorted keys, the - * payload preserves insertion order, both with `(",", ":")` separators and - * base64url-without-padding — so a TS-minted token equals a Python-minted one - * for the same claims. `tests/token.test.ts` pins this against the live Python - * mint via subprocess. - */ - -import { createHash, createHmac, timingSafeEqual } from 'crypto' -import type { Identity } from './identity' - -// ─── HS256 JWS serialization (PyJWT byte-parity) ────────────────────────────── - -function base64urlEncode(buf: Buffer | string): string { - return Buffer.from(buf).toString('base64url') -} - -/** - * Serialize a JSON object the way PyJWT does — compact `(",", ":")` separators. - * `sortKeys` matches PyJWT: the JOSE header is emitted with sorted keys; the - * payload preserves the object's own (insertion) order. Mirrors Python's - * `json.dumps(obj, separators=(",", ":"), sort_keys=...)`. - */ -function compactJson(obj: Record, sortKeys: boolean): string { - if (!sortKeys) return JSON.stringify(obj) - const sorted: Record = {} - for (const k of Object.keys(obj).sort()) sorted[k] = obj[k] - return JSON.stringify(sorted) -} - -/** - * Sign an HS256 JWS. `header` extras (e.g. `{kid}`) merge over the base - * `{alg, typ}`; the JOSE header is serialized with sorted keys, exactly as - * PyJWT's `api_jws.encode`. Returns `header.payload.signature` (base64url). - */ -export function signHs256( - payload: Record, - secret: string, - headerExtras: Record = {}, -): string { - const header = { alg: 'HS256', typ: 'JWT', ...headerExtras } - const headerB64 = base64urlEncode(compactJson(header, true)) - const payloadB64 = base64urlEncode(compactJson(payload, false)) - const signing = `${headerB64}.${payloadB64}` - const sig = createHmac('sha256', secret).update(signing).digest('base64url') - return `${signing}.${sig}` -} - -export interface MwtPayload { - sub: string - staff: boolean - 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), - } -} - -// ─── MWT mint (byte-parity with mwt.create_mwt) ─────────────────────────────── - -/** A user-shaped source for minting. Mirrors the fields create_mwt reads. */ -export interface MintUser { - pk: number | string - isStaff?: boolean - isSuperuser?: boolean - /** All permission strings, in any order (sorted here, as Python does). */ - permissions?: string[] -} - -/** - * Deterministic hash of permission state — byte-identical to - * `mwt.compute_permission_key`: SHA-256 over `"{staff}:{super}:{sorted_perms}"`. - */ -export function computePermissionKey(user: MintUser): string { - const perms = [...(user.permissions ?? [])].sort() - const staff = user.isStaff ? '1' : '0' - const superuser = user.isSuperuser ? '1' : '0' - const blob = `${staff}:${superuser}:${perms.join(',')}` - return createHash('sha256').update(blob, 'utf-8').digest('hex') -} - -/** - * Sign an MWT for `user`. Byte-identical to `mwt.create_mwt`: claims in order - * `sub, staff, super, pkey, aud, iat, nbf, exp`; `kid` in the JOSE header. - */ -export function signMwt( - user: MintUser, - secret: string, - options: { ttl?: number; audience?: string; kid?: string; now?: number } = {}, -): string { - const { ttl = 300, audience = 'mizan', kid = 'v1', now = Math.floor(Date.now() / 1000) } = options - const payload = { - sub: String(user.pk), - staff: Boolean(user.isStaff), - super: Boolean(user.isSuperuser), - pkey: computePermissionKey(user), - aud: audience, - iat: now, - nbf: now, - exp: now + ttl, - } - return signHs256(payload, secret, { kid }) -} - -/** Alias matching the `mintXxx` naming the protocol-parity surface expects. */ -export const mintMwt = signMwt - -// ─── JWT access/refresh mint (byte-parity with auth.jwt._mint) ──────────────── - -export interface JwtConfig { - privateKey: string - algorithm?: 'HS256' - accessTokenExpiresIn?: number - refreshTokenExpiresIn?: number -} - -export interface JwtMintClaims { - userId: number | string - sessionKey: string - isStaff?: boolean - isSuperuser?: boolean -} - -/** - * Mint one HS256 JWT. Byte-identical to `auth.jwt._mint`: claims in order - * `sub, sid, staff, super, type, iat, exp`. No custom JOSE header (PyJWT emits - * the bare `{alg, typ}` header for `jwt.encode` without `headers=`). - */ -export function signJwt( - claims: JwtMintClaims, - tokenType: 'access' | 'refresh', - ttl: number, - config: JwtConfig, - now: number = Math.floor(Date.now() / 1000), -): string { - const payload = { - sub: String(claims.userId), - sid: claims.sessionKey, - staff: Boolean(claims.isStaff), - super: Boolean(claims.isSuperuser), - type: tokenType, - iat: now, - exp: now + ttl, - } - return signHs256(payload, config.privateKey) -} - -export function createAccessToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string { - return signJwt(claims, 'access', config.accessTokenExpiresIn ?? 300, config, now) -} - -export function createRefreshToken(claims: JwtMintClaims, config: JwtConfig, now?: number): string { - return signJwt(claims, 'refresh', config.refreshTokenExpiresIn ?? 604800, config, now) -} - -export interface JwtTokenPair { - accessToken: string - refreshToken: string - expiresIn: number -} - -/** Mint an access+refresh pair. Mirrors `auth.jwt.create_token_pair`. */ -export function mintJwt(claims: JwtMintClaims, config: JwtConfig, now?: number): JwtTokenPair { - return { - accessToken: createAccessToken(claims, config, now), - refreshToken: createRefreshToken(claims, config, now), - expiresIn: config.accessTokenExpiresIn ?? 300, - } -} diff --git a/backends/mizan-ts/src/types.ts b/backends/mizan-ts/src/types.ts index c0509c2..bde256b 100644 --- a/backends/mizan-ts/src/types.ts +++ b/backends/mizan-ts/src/types.ts @@ -2,9 +2,6 @@ * Mizan TypeScript Adapter — Shared Types */ -import type { AuthPredicate } from './identity' -import type { IrSchema } from './ir/types' - export class ReactContext { constructor(public readonly name: string) { if (!name) throw new Error('ReactContext name must be non-empty') @@ -13,37 +10,15 @@ export class ReactContext { 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 - -/** Form role for a forms-binding function (schema / validate / submit). */ -export type FormRole = 'schema' | 'validate' | 'submit' - export interface ClientOptions { context?: ReactContext | string affects?: AffectsTarget | AffectsTarget[] - /** Contexts the mutation's return value merges into (vs. refetch). */ - merge?: AffectsTarget | AffectsTarget[] private?: boolean route?: string methods?: string[] - auth?: AuthOption - websocket?: boolean + auth?: boolean rev?: number cache?: number | false - /** - * IR type schema (input fields + output shape). TypeScript has no Pydantic - * to introspect, so the codegen IR is declared here. Without it the - * function still dispatches, but `buildIr()` cannot emit its types. - */ - ir?: IrSchema - /** Forms binding: marks this as a form function and names its role. */ - form?: boolean - formName?: string - formRole?: FormRole } export interface ParamDef { @@ -57,20 +32,14 @@ export interface RegistryEntry { fn: (...args: any[]) => Promise context?: string affects?: Array<{ type: 'context' | 'function'; name: string; context?: string }> - merge?: string[] params: ParamDef[] private: boolean viewPath: boolean route?: string methods?: string[] - auth?: AuthRequirement - websocket?: boolean + auth?: boolean rev?: number cache?: number | false - ir?: IrSchema - form?: boolean - formName?: string - formRole?: FormRole } export interface ManifestContext { diff --git a/backends/mizan-ts/src/upload.ts b/backends/mizan-ts/src/upload.ts deleted file mode 100644 index afbfdca..0000000 --- a/backends/mizan-ts/src/upload.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Mizan Upload — first-class binary input for `@client` functions. - * - * Mirrors `cores/mizan-python/src/mizan_core/upload.py`. Declaring an - * Upload-typed field in a function's `ir.input` makes a call multipart-aware: - * the generated client switches to `multipart/form-data`, and dispatch binds - * each file part into a uniform `UploadedFile` on the function's args. - * Constraints declared via `File` (max size, content types) are enforced at - * dispatch, exactly as the Python `validate_upload` enforces them. - * - * TypeScript has no Pydantic to introspect, so the Upload fields are read from - * the function's declared `ir.input` shapes (`{ kind: 'upload', ... }`) rather - * than from model metadata. - */ - -import type { RegistryEntry } from './types' -import type { TypeShape } from './ir/types' - -const SIZE_UNITS: Array<[string, number]> = [ - ['GB', 1024 ** 3], - ['MB', 1024 ** 2], - ['KB', 1024], - ['B', 1], -] - -/** Parse a byte count. Accepts a number (bytes) or a string like `"5MB"`. */ -export function parseSize(value: number | string): number { - if (typeof value === 'number') return value - const s = value.trim().toUpperCase() - for (const [unit, mult] of SIZE_UNITS) { - if (s.endsWith(unit)) return Math.trunc(parseFloat(s.slice(0, -unit.length).trim()) * mult) - } - return Math.trunc(Number(s)) -} - -/** Declarative constraints for an Upload field. */ -export interface File { - maxSize?: number - contentTypes?: string[] -} - -/** - * Uniform file handle handed to `@client` functions — adapter-agnostic. - * Constructed by dispatch from a multipart `Blob`/`File` part. - */ -export class UploadedFile { - constructor( - public readonly filename: string | null, - public readonly contentType: string | null, - private readonly data: Uint8Array, - ) {} - - get size(): number { - return this.data.byteLength - } - - read(): Uint8Array { - return this.data - } - - text(): string { - return new TextDecoder().decode(this.data) - } -} - -function contentTypeAllowed(contentType: string | null, allowed: string[]): boolean { - if (!contentType) return false - for (const ct of allowed) { - if (ct === contentType) return true - if (ct.endsWith('/*') && contentType.startsWith(ct.slice(0, -1))) return true - } - return false -} - -/** Enforce declared constraints. Returns an error message, or null if ok. */ -export function validateUpload(file: UploadedFile, spec: File | undefined): string | null { - if (!spec) return null - if (spec.maxSize !== undefined && file.size > spec.maxSize) { - return `file exceeds max size ${spec.maxSize} bytes (got ${file.size})` - } - if (spec.contentTypes && spec.contentTypes.length > 0 && !contentTypeAllowed(file.contentType, spec.contentTypes)) { - return `content-type ${JSON.stringify(file.contentType)} not allowed (expected one of ${JSON.stringify(spec.contentTypes)})` - } - return null -} - -/** An Upload field on a function input: name → (isList, spec). */ -interface UploadField { - isList: boolean - spec: File | undefined -} - -/** Unwrap Optional/list around an `upload` shape → [isUpload, isList, spec]. */ -function classifyUpload(shape: TypeShape): { isUpload: boolean; isList: boolean; spec: File | undefined } { - let s = shape - if (s.kind === 'optional') s = s.inner - let isList = false - if (s.kind === 'list') { - isList = true - s = s.inner - } - if (s.kind === 'upload') { - const spec: File = {} - if (s.maxSize !== undefined) spec.maxSize = s.maxSize - if (s.contentTypes !== undefined) spec.contentTypes = s.contentTypes - const hasSpec = s.maxSize !== undefined || s.contentTypes !== undefined - return { isUpload: true, isList, spec: hasSpec ? spec : undefined } - } - return { isUpload: false, isList: false, spec: undefined } -} - -/** Map each Upload-typed field of a function's input → (isList, spec). */ -export function uploadFields(entry: RegistryEntry): Map { - const out = new Map() - for (const field of entry.ir?.input ?? []) { - const { isUpload, isList, spec } = classifyUpload(field.shape) - if (isUpload) out.set(field.name, { isList, spec }) - } - return out -} - -/** - * Place uploaded files into `args` by field name, enforcing constraints. - * Mutates `args` in place. `files` maps a field name to the parts received for - * it (a list field receives several). Returns an error message on the first - * constraint violation, else null. Mirrors `upload.bind_uploads`. - */ -export function bindUploads( - entry: RegistryEntry, - args: Record, - files: Map, -): string | null { - for (const [name, { isList, spec }] of uploadFields(entry)) { - const bucket = files.get(name) ?? [] - if (bucket.length === 0) continue - for (const f of bucket) { - const err = validateUpload(f, spec) - if (err !== null) return `${name}: ${err}` - } - args[name] = isList ? [...bucket] : bucket[0] - } - return null -} diff --git a/backends/mizan-ts/src/websocket.ts b/backends/mizan-ts/src/websocket.ts deleted file mode 100644 index f389291..0000000 --- a/backends/mizan-ts/src/websocket.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * WebSocket transport — RPC over a WebSocket connection for - * `@client({ websocket: true })` functions. - * - * Parity with the Django Channels consumer and the Axum WebSocket handler: the - * client sends JSON-RPC frames and receives correlated replies. Both the - * mutation (`call`) and the bundled context (`fetch`) verbs route through the - * *same* dispatch core the HTTP path uses, so invalidation, auth, and caching - * behave identically on either transport — only the framing differs. - * - * Frame protocol (newline-free JSON, one object per WS message): - * - * → { "id": 1, "type": "call", "fn": "update_profile", "args": { ... } } - * ← { "id": 1, "result": { ... }, "invalidate": [ ... ] } - * - * → { "id": 2, "type": "fetch", "context": "user", "params": { ... } } - * ← { "id": 2, "result": { user_profile: { ... }, ... } } - * - * ← { "id": N, "error": { "code": "...", "message": "..." } } (on failure) - * - * The `id` echoes back so a client can correlate concurrent in-flight calls - * over one socket. - */ - -import { handleContextFetch, handleMutationCall } from './dispatch' -import { ANONYMOUS, type Identity } from './identity' - -interface CallFrame { - id?: number | string - type: 'call' - fn: string - args?: Record -} - -interface FetchFrame { - id?: number | string - type: 'fetch' - context: string - params?: Record -} - -export type MizanWsFrame = CallFrame | FetchFrame - -export interface MizanWsReply { - id?: number | string - result?: any - invalidate?: any - error?: { code: string; message: string } -} - -/** - * Handle one inbound WebSocket frame and produce the reply object. - * - * `raw` is the message payload (string or already-parsed object). Routing is by - * the frame `type`; the body of the work is the same dispatch the HTTP handlers - * call, so a function exposed over both transports behaves identically. - */ -export async function handleWebSocketMessage( - raw: string | MizanWsFrame, - identity: Identity = ANONYMOUS, -): Promise { - let frame: MizanWsFrame - try { - frame = typeof raw === 'string' ? JSON.parse(raw) : raw - } catch { - return { error: { code: 'BAD_REQUEST', message: 'Invalid JSON frame' } } - } - - const id = (frame as { id?: number | string }).id - - if (frame.type === 'call') { - if (!frame.fn) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'fn'" } } - const res = await handleMutationCall(frame.fn, frame.args ?? {}, identity) - if (res.status !== 200) { - return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } } - } - const reply: MizanWsReply = { id, result: res.body.result } - if (res.body.invalidate !== undefined) reply.invalidate = res.body.invalidate - return reply - } - - if (frame.type === 'fetch') { - if (!frame.context) return { id, error: { code: 'BAD_REQUEST', message: "Missing 'context'" } } - const res = await handleContextFetch(frame.context, frame.params ?? {}, identity) - if (res.status !== 200) { - return { id, error: { code: res.body.code ?? 'ERROR', message: res.body.message ?? 'Error' } } - } - return { id, result: res.body } - } - - return { id, error: { code: 'BAD_REQUEST', message: `Unknown frame type` } } -} - -/** Minimal structural type for a WebSocket-like connection. */ -export interface WebSocketLike { - send(data: string): void - addEventListener(type: 'message', listener: (event: { data: any }) => void): void -} - -/** - * Attach the Mizan RPC protocol to a `WebSocket`-like connection. Each inbound - * message is dispatched via `handleWebSocketMessage` and the reply is sent back - * as JSON. `identity` resolves the caller (host wires MWT/JWT decode here). - */ -export function serveWebSocket( - ws: WebSocketLike, - identity: Identity = ANONYMOUS, -): void { - ws.addEventListener('message', async (event) => { - const reply = await handleWebSocketMessage( - typeof event.data === 'string' ? event.data : String(event.data), - identity, - ) - ws.send(JSON.stringify(reply)) - }) -} diff --git a/backends/mizan-ts/tests/auth.test.ts b/backends/mizan-ts/tests/auth.test.ts deleted file mode 100644 index 10b1fe6..0000000 --- a/backends/mizan-ts/tests/auth.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/backends/mizan-ts/tests/fixtures/Hello.tsx b/backends/mizan-ts/tests/fixtures/Hello.tsx deleted file mode 100644 index 8af1359..0000000 --- a/backends/mizan-ts/tests/fixtures/Hello.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createElement } from 'react' - -/** SSR fixture component — rendered by the Bun worker in the bridge test. */ -export default function Hello({ name }: { name: string }) { - return createElement('div', { className: 'greeting' }, `Hello, ${name}!`) -} diff --git a/backends/mizan-ts/tests/fixtures/stub-worker.mjs b/backends/mizan-ts/tests/fixtures/stub-worker.mjs deleted file mode 100644 index 4d8adcb..0000000 --- a/backends/mizan-ts/tests/fixtures/stub-worker.mjs +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited - * JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React - * dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess - * machinery (ready handshake, id correlation, render reply, ping, error frame) - * under plain Node, independent of the real worker's install state. - * - * `render` echoes the props into a deterministic HTML string so the bridge's - * request/response correlation is observable; a file named "*boom*" yields an - * error frame to prove the failure path. - */ - -function respond(msg) { - process.stdout.write(JSON.stringify(msg) + '\n') -} - -function handle(msg) { - if (msg.method === 'ping') { - respond({ id: msg.id, pong: true }) - return - } - if (msg.method === 'render') { - const { file, props } = msg.params ?? {} - if (typeof file === 'string' && file.includes('boom')) { - respond({ id: msg.id, error: `cannot render ${file}` }) - return - } - respond({ id: msg.id, html: `
${JSON.stringify(props ?? {})}
` }) - return - } - respond({ id: msg.id, error: `Unknown method: ${msg.method}` }) -} - -let buffer = '' -process.stdin.setEncoding('utf-8') -process.stdin.on('data', (chunk) => { - buffer += chunk - let nl - while ((nl = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, nl).trim() - buffer = buffer.slice(nl + 1) - if (line) { - try { - handle(JSON.parse(line)) - } catch (e) { - respond({ id: -1, error: e.message }) - } - } - } -}) - -// Ready handshake — identical to the real worker. -respond({ id: 0, ready: true }) diff --git a/backends/mizan-ts/tests/ir-fixture.ts b/backends/mizan-ts/tests/ir-fixture.ts deleted file mode 100644 index 1871bf3..0000000 --- a/backends/mizan-ts/tests/ir-fixture.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1. - * - * Each function declares the same IR type schema the Python fixture's Pydantic - * Input/Output models imply, so `buildIr()` here emits the same KDL the Python - * `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`) - * subprocesses the live Python emitter and asserts equality. - * - * Output structs are declared under their model name (`ProfileOutput`, - * `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames - * them to the canonical `Output`, exactly as `_collect_named_types` - * renames the Pydantic models. - */ - -import { client, ReactContext } from '../src' -import type { NamedType, StructField } from '../src' - -const intField = (name: string): StructField => ({ - name, - required: true, - shape: { kind: 'primitive', primitive: 'integer' }, -}) -const strField = (name: string): StructField => ({ - name, - required: true, - shape: { kind: 'primitive', primitive: 'string' }, -}) -const boolField = (name: string): StructField => ({ - name, - required: true, - shape: { kind: 'primitive', primitive: 'boolean' }, -}) - -const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] } -const OrderOutput: NamedType = { - kind: 'struct', - fields: [intField('id'), intField('user_id'), intField('total')], -} - -const UserCtx = new ReactContext('user') - -/** Register the AFI fixture functions with the mizan-ts registry. */ -export function registerFixture(): void { - // echo — plain function, typed input + struct output. - client( - { - ir: { - input: [strField('text')], - output: { kind: 'ref', name: 'EchoOutput' }, - types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } }, - }, - }, - async function echo(text: string) { - return { message: `echo: ${text}` } - }, - ) - - // whoami — no input. - client( - { - ir: { - output: { kind: 'ref', name: 'WhoamiOutput' }, - types: { - WhoamiOutput: { - kind: 'struct', - fields: [strField('email'), boolField('authenticated')], - }, - }, - }, - }, - async function whoami() { - return { email: 'anon@example.com', authenticated: false } - }, - ) - - // user_profile — context member. - client( - { - context: UserCtx, - ir: { - input: [intField('user_id')], - output: { kind: 'ref', name: 'ProfileOutput' }, - types: { ProfileOutput }, - }, - }, - async function user_profile(user_id: number) { - return { user_id, name: 'placeholder' } - }, - ) - - // user_orders — context member, list output, same param (param elevation). - client( - { - context: UserCtx, - ir: { - input: [intField('user_id')], - output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } }, - types: { OrderOutput }, - }, - }, - async function user_orders(_user_id: number) { - return [] - }, - ) - - // update_profile — mutation affecting the user context. - client( - { - affects: UserCtx, - ir: { - input: [intField('user_id'), strField('name')], - output: { kind: 'ref', name: 'StatusOutput' }, - types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } }, - }, - }, - async function update_profile(_user_id: number, _name: string) { - return { ok: true } - }, - ) - - // find_user — optional return. - client( - { - ir: { - input: [intField('user_id')], - output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } }, - types: { ProfileOutput }, - }, - }, - async function find_user(_user_id: number) { - return null - }, - ) - - // rename_user — merge target. - client( - { - merge: UserCtx, - ir: { - input: [intField('user_id'), strField('name')], - output: { kind: 'ref', name: 'ProfileOutput' }, - types: { ProfileOutput }, - }, - }, - async function rename_user(user_id: number, name: string) { - return { user_id, name } - }, - ) -} diff --git a/backends/mizan-ts/tests/ir.test.ts b/backends/mizan-ts/tests/ir.test.ts deleted file mode 100644 index ca6446e..0000000 --- a/backends/mizan-ts/tests/ir.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python - * `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`). - * - * The IR is the codegen contract. A TypeScript backend can only feed - * `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends - * emit for the same registry. This test reconstructs the AFI fixture in both - * languages, subprocesses the live Python emitter, and asserts byte-equality — - * the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies. - */ - -import { describe, test, expect, beforeEach } from 'bun:test' -import { execFileSync } from 'child_process' -import { existsSync } from 'fs' -import { resolve } from 'path' -import { buildIr, clearRegistry } from '../src' -import { registerFixture } from './ir-fixture' - -const REPO_ROOT = resolve(import.meta.dir, '../../..') -const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python') - -/** - * Reconstruct the AFI fixture in Python via `mizan_core` only (no backend - * adapter dependency) and emit `build_ir()`. This is the cross-language oracle: - * the same registrations the TS fixture makes, run through the reference - * emitter. - */ -const PY_FIXTURE = String.raw` -import sys -from typing import Optional -from pydantic import BaseModel -from mizan_core.client.function import client -from mizan_core import registry as reg -from mizan_core.ir import build_ir - -reg.clear_registry() - -class EchoOutput(BaseModel): - message: str - -class WhoamiOutput(BaseModel): - email: str - authenticated: bool - -class ProfileOutput(BaseModel): - user_id: int - name: str - -class OrderOutput(BaseModel): - id: int - user_id: int - total: int - -class StatusOutput(BaseModel): - ok: bool - -@client -def echo(request, text: str) -> EchoOutput: ... - -@client -def whoami(request) -> WhoamiOutput: ... - -@client(context="user") -def user_profile(request, user_id: int) -> ProfileOutput: ... - -@client(context="user") -def user_orders(request, user_id: int) -> list[OrderOutput]: ... - -@client(affects="user") -def update_profile(request, user_id: int, name: str) -> StatusOutput: ... - -@client -def find_user(request, user_id: int) -> Optional[ProfileOutput]: ... - -@client(merge="user") -def rename_user(request, user_id: int, name: str) -> ProfileOutput: ... - -for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]: - reg.register(f, f.__name__) - -sys.stdout.write(build_ir()) -` - -function pythonBuildIr(): string { - return execFileSync( - 'uv', - ['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE], - { encoding: 'utf-8' }, - ) -} - -const UV_AVAILABLE = (() => { - try { - execFileSync('uv', ['--version'], { stdio: 'ignore' }) - return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml')) - } catch { - return false - } -})() - -describe('KDL IR — buildIr()', () => { - beforeEach(() => clearRegistry()) - - test('emits the canonical type / function / context sections', () => { - registerFixture() - const kdl = buildIr() - - // Types are alphabetical; output structs renamed to Output. - expect(kdl).toContain('type "OrderOutput" {') - expect(kdl).toContain('type "echoInput" {') - expect(kdl).toContain('type "findUserOutput" {') - expect(kdl).toContain('type "userOrdersOutput" {') - - // Functions alphabetical, with transport + context/affects/merge leaves. - expect(kdl).toContain('function "echo" {') - expect(kdl).toContain(' camel "echo"') - expect(kdl).toContain(' has-input #true') - expect(kdl).toContain(' output-nullable #true') // find_user - expect(kdl).toContain(' affects "user"') // update_profile - expect(kdl).toContain(' merge "user"') // rename_user - - // Context section with shared param elevation. - expect(kdl).toContain('context "user" {') - expect(kdl).toContain(' shared-by "user_orders"') - expect(kdl).toContain(' shared-by "user_profile"') - }) - - test('has-input #false for a no-arg function', () => { - registerFixture() - const kdl = buildIr() - const whoami = kdl.slice(kdl.indexOf('function "whoami" {')) - expect(whoami).toContain('has-input #false') - expect(whoami).not.toContain('input "whoamiInput"') - }) - - test.skipIf(!UV_AVAILABLE)( - 'byte-identical to the Python build_ir() (cores/mizan-python)', - () => { - registerFixture() - const tsKdl = buildIr() - const pyKdl = pythonBuildIr() - - // Line-by-line first so a divergence names the offending line. - const tsLines = tsKdl.split('\n') - const pyLines = pyKdl.split('\n') - const n = Math.max(tsLines.length, pyLines.length) - for (let i = 0; i < n; i++) { - if (tsLines[i] !== pyLines[i]) { - throw new Error( - `KDL diverges at line ${i + 1}:\n` + - ` python: ${JSON.stringify(pyLines[i])}\n` + - ` ts: ${JSON.stringify(tsLines[i])}`, - ) - } - } - expect(tsKdl).toBe(pyKdl) - }, - ) -}) diff --git a/backends/mizan-ts/tests/shapes-forms.test.ts b/backends/mizan-ts/tests/shapes-forms.test.ts deleted file mode 100644 index b078406..0000000 --- a/backends/mizan-ts/tests/shapes-forms.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Shapes (typed query projection) + Forms (schema / validate / submit) tests. - * - * Shapes prove over-fetch elimination: the projected record carries only the - * declared fields + nested relations, nothing else. Forms prove the three - * roles register as dispatchable `@client` functions carrying the IR's - * `form`/`form-name`/`form-role` meta, and that validate/submit enforce the - * declared field rules. - */ - -import { describe, test, expect, beforeEach } from 'bun:test' -import { - clearRegistry, - getFunction, - handleMutationCall, - Shape, - project, - registerForm, - formSchema, - validateForm, - type QueryProjection, -} from '../src' - -describe('Shapes — typed query projection', () => { - test('keeps only declared scalar fields', () => { - const projection: QueryProjection = { fields: ['id', 'name'] } - const out = project([{ id: 1, name: 'A', secret: 'x', internal: 42 }], projection) - expect(out).toEqual([{ id: 1, name: 'A' }]) - expect(out[0]).not.toHaveProperty('secret') - expect(out[0]).not.toHaveProperty('internal') - }) - - test('prunes nested relations recursively', () => { - const projection: QueryProjection = { - fields: ['id'], - relations: { orders: { fields: ['total'] } }, - } - const out = project( - [{ id: 1, name: 'drop', orders: [{ id: 9, total: 100, hidden: true }] }], - projection, - ) - expect(out).toEqual([{ id: 1, orders: [{ total: 100 }] }]) - expect(out[0].orders[0]).not.toHaveProperty('hidden') - }) - - test('handles single-object relation + null', () => { - const projection: QueryProjection = { - fields: ['id'], - relations: { profile: { fields: ['bio'] } }, - } - const out = project( - [ - { id: 1, profile: { bio: 'hi', age: 30 } }, - { id: 2, profile: null }, - ], - projection, - ) - expect(out[0]).toEqual({ id: 1, profile: { bio: 'hi' } }) - expect(out[1]).toEqual({ id: 2, profile: null }) - }) - - test('Shape.query binds a projection to a source', () => { - const UserShape = new Shape('user', { fields: ['id', 'email'] }) - const out = UserShape.query([{ id: 1, email: 'a@b.c', password: 'nope' }]) - expect(out).toEqual([{ id: 1, email: 'a@b.c' }]) - }) -}) - -describe('Forms — schema / validate / submit', () => { - beforeEach(() => clearRegistry()) - - const contactForm = { - fields: [ - { name: 'email', type: 'email', required: true, label: 'Email' }, - { - name: 'age', - type: 'number', - required: false, - validate: (v: unknown) => (Number(v) < 0 ? 'must be non-negative' : null), - }, - ], - } - - test('formSchema produces field definitions', () => { - const schema = formSchema(contactForm) - expect(schema.fields).toHaveLength(2) - expect(schema.fields[0]).toEqual({ - name: 'email', - type: 'email', - required: true, - label: 'Email', - helpText: '', - choices: null, - initial: null, - }) - // Default label derived from name when omitted. - expect(schema.fields[1].label).toBe('Age') - }) - - test('validateForm: required + custom validator', () => { - expect(validateForm(contactForm, { email: 'a@b.c' }).valid).toBe(true) - expect(validateForm(contactForm, {}).errors.email).toEqual(['This field is required.']) - expect(validateForm(contactForm, { email: 'a@b.c', age: -1 }).errors.age).toEqual([ - 'must be non-negative', - ]) - }) - - test('registerForm registers schema + validate + submit with form meta', () => { - const reg = registerForm(contactForm, 'contact', { - submit: async (data) => ({ saved: data.email }), - }) - expect(reg).toEqual({ schema: 'contact-schema', validate: 'contact-validate', submit: 'contact-submit' }) - - for (const [wire, role] of [ - ['contact-schema', 'schema'], - ['contact-validate', 'validate'], - ['contact-submit', 'submit'], - ] as const) { - const entry = getFunction(wire) - expect(entry).toBeDefined() - expect(entry!.form).toBe(true) - expect(entry!.formName).toBe('contact') - expect(entry!.formRole).toBe(role) - } - }) - - test('schema function dispatches to the field defs', async () => { - registerForm(contactForm, 'contact') - const r = await handleMutationCall('contact-schema', {}) - expect(r.status).toBe(200) - expect(r.body.result.fields).toHaveLength(2) - }) - - test('validate function dispatches and rejects bad data', async () => { - registerForm(contactForm, 'contact') - const ok = await handleMutationCall('contact-validate', { data: { email: 'a@b.c' } }) - expect(ok.body.result.valid).toBe(true) - - const bad = await handleMutationCall('contact-validate', { data: {} }) - expect(bad.body.result.valid).toBe(false) - expect(bad.body.result.errors.email).toBeDefined() - }) - - test('submit validates then runs the handler', async () => { - let handled: any = null - registerForm(contactForm, 'contact', { - submit: async (data) => { - handled = data - return { id: 7 } - }, - }) - - const ok = await handleMutationCall('contact-submit', { data: { email: 'a@b.c' } }) - expect(ok.body.result).toEqual({ ok: true, result: { id: 7 } }) - expect(handled).toEqual({ email: 'a@b.c' }) - - const bad = await handleMutationCall('contact-submit', { data: {} }) - expect(bad.body.result.ok).toBe(false) - expect(bad.body.result.errors.email).toBeDefined() - }) - - test('submit not registered without a handler', () => { - const reg = registerForm(contactForm, 'noSubmit') - expect(reg.submit).toBeUndefined() - expect(getFunction('noSubmit-submit')).toBeUndefined() - }) -}) diff --git a/backends/mizan-ts/tests/ssr.test.ts b/backends/mizan-ts/tests/ssr.test.ts deleted file mode 100644 index 2fd9c98..0000000 --- a/backends/mizan-ts/tests/ssr.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * SSR bridge tests — spawn + drive a JSON-RPC worker subprocess. - * - * The bridge's contract is the newline-delimited JSON-RPC protocol over a - * spawned worker (ready handshake, id-correlated render/ping, error frames, - * timeout, restart). Two peers exercise it: - * - * - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always - * runs, proving the full subprocess machinery independent of any install; - * - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an - * actual React component — runs when `bun` + the worker's deps are present. - */ - -import { describe, test, expect, afterEach } from 'bun:test' -import { execFileSync } from 'child_process' -import { existsSync } from 'fs' -import { resolve } from 'path' -import { SSRBridge } from '../src' - -const HERE = import.meta.dir -const REPO_ROOT = resolve(HERE, '../../..') -const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs') -const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx') -const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx') - -// The real worker renders an actual React component. bun resolves `react` -// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's -// own react devDependency (installed alongside this package). -const BUN_OK = (() => { - try { - execFileSync('bun', ['--version'], { stdio: 'ignore' }) - return existsSync(resolve(HERE, '../node_modules/react/package.json')) - } catch { - return false - } -})() - -let bridge: SSRBridge | null = null -afterEach(() => { - bridge?.shutdown() - bridge = null -}) - -describe('SSRBridge — stub worker (Node, no React)', () => { - test('waits for ready, then renders with id correlation', async () => { - bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] }) - const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 }) - expect(r.html).toBe('
{"title":"Hi","n":3}
') - }) - - test('ping health check', async () => { - bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] }) - expect(await bridge.ping()).toBe(true) - }) - - test('concurrent renders stay correlated', async () => { - bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] }) - const [a, b, c] = await Promise.all([ - bridge.render('/a.tsx', { k: 'a' }), - bridge.render('/b.tsx', { k: 'b' }), - bridge.render('/c.tsx', { k: 'c' }), - ]) - expect(a.html).toContain('"k":"a"') - expect(b.html).toContain('"k":"b"') - expect(c.html).toContain('"k":"c"') - }) - - test('worker error frame surfaces as a thrown error', async () => { - bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] }) - await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed') - }) - - test('restarts after the worker exits', async () => { - bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] }) - const first = await bridge.render('/one.tsx', { k: 1 }) - expect(first.html).toContain('"k":1') - bridge.shutdown() // simulate a crashed/stopped worker - const second = await bridge.render('/two.tsx', { k: 2 }) - expect(second.html).toContain('"k":2') - }) - - test('startup timeout when the worker never signals ready', async () => { - // `true` exits immediately without a ready frame → start times out. - bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 }) - await expect(bridge.render('/x.tsx', {})).rejects.toThrow() - }) -}) - -describe('SSRBridge — real Bun worker (renderToString)', () => { - test.skipIf(!BUN_OK)('renders a React component to HTML', async () => { - bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' }) - const r = await bridge.render(HELLO_TSX, { name: 'Ryth' }) - expect(r.html).toContain('Hello, Ryth!') - expect(r.html).toContain('class="greeting"') - }) - - test.skipIf(!BUN_OK)('ping on the real worker', async () => { - bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' }) - expect(await bridge.ping()).toBe(true) - }) -}) diff --git a/backends/mizan-ts/tests/token.test.ts b/backends/mizan-ts/tests/token.test.ts deleted file mode 100644 index 6f03cc4..0000000 --- a/backends/mizan-ts/tests/token.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * MWT / JWT token tests — decode round-trip + cross-language byte-parity pins - * against the live Python mint (`cores/mizan-python`). - */ - -import { describe, test, expect } from 'bun:test' -import { createHmac, createHash } from 'crypto' -import { execFileSync } from 'child_process' -import { existsSync } from 'fs' -import { resolve } from 'path' -import { - decodeMwt, - decodeJwtBearer, - identityFromMwt, - signMwt, - computePermissionKey, - createAccessToken, - createRefreshToken, - type MintUser, -} from '../src' - -function b64url(buf: Buffer | string): string { - return Buffer.from(buf).toString('base64url') -} - -/** Mint an HS256 MWT with node crypto, mirroring Python create_mwt. */ -function mint(payload: Record, 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 = {}) { - 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, - }) - }) -}) - -// ─── Mint: round-trip + cross-language byte-parity ──────────────────────────── - -const REPO_ROOT = resolve(import.meta.dir, '../../..') -const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python') - -const UV_AVAILABLE = (() => { - try { - execFileSync('uv', ['--version'], { stdio: 'ignore' }) - return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml')) - } catch { - return false - } -})() - -/** - * Run a Python snippet against cores/mizan-python and return stdout (trimmed). - * `time.time` is pinned so the production mint functions are deterministic. - */ -function py(snippet: string): string { - return execFileSync('uv', ['run', '--project', MIZAN_PYTHON, 'python', '-c', snippet], { - encoding: 'utf-8', - }).trim() -} - -describe('MWT mint — round-trip', () => { - const SECRET = 'mint-roundtrip-secret' - - test('signMwt produces a token decodeMwt accepts', () => { - const user: MintUser = { pk: 7, isStaff: true, isSuperuser: false, permissions: ['a.view', 'a.edit'] } - const token = signMwt(user, SECRET, { now: Math.floor(Date.now() / 1000) }) - const p = decodeMwt(token, SECRET) - expect(p).not.toBeNull() - expect(p!.sub).toBe('7') - expect(p!.staff).toBe(true) - expect(p!.super).toBe(false) - expect(p!.kid).toBe('v1') - expect(p!.aud).toBe('mizan') - // pkey is the permission hash, surviving the round-trip. - expect(p!.pkey).toBe(computePermissionKey(user)) - }) - - test('computePermissionKey matches the documented blob hash', () => { - const user: MintUser = { pk: 1, isStaff: true, isSuperuser: false, permissions: ['z', 'a'] } - // "1:0:a,z" — staff:super:sorted-perms. - const expected = createHash('sha256').update('1:0:a,z', 'utf-8').digest('hex') - expect(computePermissionKey(user)).toBe(expected) - }) -}) - -describe('MWT mint — cross-language pin (Python create_mwt)', () => { - const SECRET = 'pin-mint-secret-mwt' - const NOW = 1700000000 - - test.skipIf(!UV_AVAILABLE)('TS signMwt byte-identical to Python create_mwt', () => { - const user: MintUser = { - pk: 42, - isStaff: true, - isSuperuser: false, - permissions: ['app.view_thing', 'app.change_thing'], - } - const tsToken = signMwt(user, SECRET, { ttl: 300, now: NOW }) - - // Drive the REAL create_mwt with time.time pinned to NOW and a - // user stub whose get_all_permissions returns the same perms. - const pyToken = py(String.raw` -import time, sys -time.time = lambda: ${NOW} -from mizan_core.mwt import create_mwt - -class U: - pk = 42 - is_staff = True - is_superuser = False - def get_all_permissions(self): - return {"app.view_thing", "app.change_thing"} - -sys.stdout.write(create_mwt(U(), ${JSON.stringify(SECRET)}, ttl=300)) -`) - expect(tsToken).toBe(pyToken) - }) -}) - -describe('JWT mint — cross-language pin (Python create_access/refresh_token)', () => { - const SECRET = 'pin-mint-secret-jwt' - const NOW = 1700000000 - - const config = { privateKey: SECRET, accessTokenExpiresIn: 300, refreshTokenExpiresIn: 604800 } - const claims = { userId: 42, sessionKey: 'sess-abc', isStaff: true, isSuperuser: false } - - test.skipIf(!UV_AVAILABLE)('TS createAccessToken byte-identical to Python', () => { - const tsToken = createAccessToken(claims, config, NOW) - const pyToken = py(String.raw` -import time, sys -time.time = lambda: ${NOW} -from mizan_core.auth.jwt import JWTConfig, create_access_token -cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)}) -sys.stdout.write(create_access_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False)) -`) - expect(tsToken).toBe(pyToken) - }) - - test.skipIf(!UV_AVAILABLE)('TS createRefreshToken byte-identical to Python', () => { - const tsToken = createRefreshToken(claims, config, NOW) - const pyToken = py(String.raw` -import time, sys -time.time = lambda: ${NOW} -from mizan_core.auth.jwt import JWTConfig, create_refresh_token -cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)}) -sys.stdout.write(create_refresh_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False)) -`) - expect(tsToken).toBe(pyToken) - }) -}) diff --git a/backends/mizan-ts/tests/transport.test.ts b/backends/mizan-ts/tests/transport.test.ts deleted file mode 100644 index 52e9c8c..0000000 --- a/backends/mizan-ts/tests/transport.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Session-init + WebSocket transport tests. - * - * session-init returns the `{ csrfToken }` no-store shape at parity with the - * Django/FastAPI/Axum session endpoint. The WebSocket transport drives the - * SAME dispatch core the HTTP path uses, so a function exposed over WS behaves - * identically — invalidation, auth, and not-found all carry through. - */ - -import { describe, test, expect, beforeEach } from 'bun:test' -import { - ReactContext, - client, - clearRegistry, - handleSessionInit, - sessionInitRoute, - SESSION_INIT_PATH, - handleWebSocketMessage, - serveWebSocket, - type Identity, - type WebSocketLike, -} from '../src' - -describe('session-init', () => { - test('returns { csrfToken: null } with no-store', () => { - const r = handleSessionInit() - expect(r.status).toBe(200) - expect(r.body).toEqual({ csrfToken: null }) - expect(r.headers['Cache-Control']).toBe('no-store') - expect(r.headers['Content-Type']).toBe('application/json') - }) - - test('embeds a host-provided CSRF token', () => { - const r = handleSessionInit('tok-123') - expect(r.body).toEqual({ csrfToken: 'tok-123' }) - }) - - test('route descriptor mounts GET /session/ (parity with Django/FastAPI/Axum)', () => { - expect(SESSION_INIT_PATH).toBe('/session/') - expect(sessionInitRoute.path).toBe('/session/') - expect(sessionInitRoute.method).toBe('GET') - // The wired handler returns the session shape. - expect(sessionInitRoute.handler().body).toEqual({ csrfToken: null }) - }) -}) - -describe('WebSocket transport', () => { - beforeEach(() => clearRegistry()) - - const UserCtx = new ReactContext('user') - - function setup() { - client({ context: UserCtx, websocket: true }, async function user_profile(user_id: number) { - return { user_id, name: `user_${user_id}` } - }) - client({ affects: UserCtx, websocket: true }, async function update_profile(user_id: number, name: string) { - return { ok: true, user_id, name } - }) - } - - test('call frame routes through mutation dispatch + carries invalidation', async () => { - setup() - const reply = await handleWebSocketMessage({ - id: 1, - type: 'call', - fn: 'update_profile', - args: { user_id: 5, name: 'X' }, - }) - expect(reply.id).toBe(1) - expect(reply.result).toEqual({ ok: true, user_id: 5, name: 'X' }) - expect(reply.invalidate).toBeDefined() - expect(reply.invalidate[0].context).toBe('user') - expect(reply.invalidate[0].params.user_id).toBe(5) - }) - - test('fetch frame routes through context bundle', async () => { - setup() - const reply = await handleWebSocketMessage({ - id: 2, - type: 'fetch', - context: 'user', - params: { user_id: '7' }, - }) - expect(reply.id).toBe(2) - expect(reply.result.user_profile).toEqual({ user_id: '7', name: 'user_7' }) - }) - - test('unknown function returns an error frame, not a throw', async () => { - const reply = await handleWebSocketMessage({ id: 3, type: 'call', fn: 'nope' }) - expect(reply.error).toBeDefined() - expect(reply.error!.code).toBe('NOT_FOUND') - expect(reply.id).toBe(3) - }) - - test('auth enforcement carries over the WS transport', async () => { - client({ auth: true, websocket: true }, async function secret() { - return { ok: true } - }) - const anon: Identity = { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null } - const reply = await handleWebSocketMessage({ id: 4, type: 'call', fn: 'secret' }, anon) - expect(reply.error!.code).toBe('UNAUTHORIZED') - }) - - test('malformed JSON frame → error', async () => { - const reply = await handleWebSocketMessage('{not json') - expect(reply.error!.code).toBe('BAD_REQUEST') - }) - - test('serveWebSocket wires a connection and replies as JSON', async () => { - setup() - const sent: string[] = [] - let listener: ((e: { data: any }) => void) | null = null - const ws: WebSocketLike = { - send: (d) => sent.push(d), - addEventListener: (_t, l) => { - listener = l - }, - } - serveWebSocket(ws) - expect(listener).not.toBeNull() - - // Drive a message through the wired listener. - await listener!({ data: JSON.stringify({ id: 9, type: 'fetch', context: 'user', params: { user_id: '3' } }) }) - // Give the async handler a tick to resolve + send. - await new Promise((r) => setTimeout(r, 0)) - expect(sent.length).toBe(1) - const reply = JSON.parse(sent[0]) - expect(reply.id).toBe(9) - expect(reply.result.user_profile.name).toBe('user_3') - }) -}) diff --git a/backends/mizan-ts/tests/upload.test.ts b/backends/mizan-ts/tests/upload.test.ts deleted file mode 100644 index 8d93ddb..0000000 --- a/backends/mizan-ts/tests/upload.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Upload tests — multipart File-part binding + constraint enforcement. - * - * Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts - * into the function's Upload-typed inputs, and `File(...)` constraints - * (max-size, content-type) reject at dispatch with a 400. - */ - -import { describe, test, expect, beforeEach } from 'bun:test' -import { - client, - clearRegistry, - handleMultipartCall, - parseSize, - validateUpload, - UploadedFile, - type StructField, -} from '../src' - -const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => { - let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes } - if (opts.list) shape = { kind: 'list', inner: shape } - if (opts.optional) shape = { kind: 'optional', inner: shape } - return { name, required: !opts.optional, shape } -} -const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } }) - -function multipart(fn: string, args: Record, files: Record): FormData { - const form = new FormData() - form.set('fn', fn) - form.set('args', JSON.stringify(args)) - for (const [key, val] of Object.entries(files)) { - for (const f of Array.isArray(val) ? val : [val]) form.append(key, f) - } - return form -} - -describe('parseSize', () => { - test('parses human sizes', () => { - expect(parseSize('5MB')).toBe(5 * 1024 * 1024) - expect(parseSize('1KB')).toBe(1024) - expect(parseSize('2GB')).toBe(2 * 1024 ** 3) - expect(parseSize(123)).toBe(123) - expect(parseSize('500')).toBe(500) - }) -}) - -describe('validateUpload', () => { - test('max-size rejection', () => { - const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100)) - expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size') - expect(validateUpload(f, { maxSize: 200 })).toBeNull() - }) - - test('content-type allowlist + wildcard', () => { - const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1)) - expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull() - expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull() - expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed') - }) -}) - -describe('multipart dispatch', () => { - beforeEach(() => clearRegistry()) - - test('binds a file part into the Upload input', async () => { - let received: UploadedFile | null = null - client( - { - affects: 'avatars', - ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] }, - }, - async function set_avatar(user_id: number, avatar: UploadedFile) { - received = avatar - return { ok: true, name: avatar.filename, bytes: avatar.size } - }, - ) - - const form = multipart('set_avatar', { user_id: 5 }, { - avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }), - }) - const r = await handleMultipartCall(form) - expect(r.status).toBe(200) - expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 }) - expect(received).not.toBeNull() - expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4])) - }) - - test('max-size violation rejects with 400', async () => { - client( - { affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } }, - async function set_avatar(_avatar: UploadedFile) { - return { ok: true } - }, - ) - const form = multipart('set_avatar', {}, { - avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }), - }) - const r = await handleMultipartCall(form) - expect(r.status).toBe(400) - expect(r.body.message).toContain('avatar:') - expect(r.body.message).toContain('exceeds max size') - }) - - test('content-type violation rejects with 400', async () => { - client( - { affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } }, - async function set_avatar(_avatar: UploadedFile) { - return { ok: true } - }, - ) - const form = multipart('set_avatar', {}, { - avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }), - }) - const r = await handleMultipartCall(form) - expect(r.status).toBe(400) - expect(r.body.message).toContain('not allowed') - }) - - test('list upload binds multiple parts', async () => { - let count = 0 - client( - { affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } }, - async function add_photos(photos: UploadedFile[]) { - count = photos.length - return { ok: true, count: photos.length } - }, - ) - const form = multipart('add_photos', {}, { - photos: [ - new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }), - new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }), - ], - }) - const r = await handleMultipartCall(form) - expect(r.status).toBe(200) - expect(r.body.result.count).toBe(2) - expect(count).toBe(2) - }) - - test('missing fn → 400', async () => { - const form = new FormData() - form.set('args', '{}') - const r = await handleMultipartCall(form) - expect(r.status).toBe(400) - expect(r.body.message).toContain("'fn'") - }) - - test('invalidation still emitted on multipart mutation', async () => { - client( - { affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } }, - async function set_avatar(_user_id: number, _avatar: UploadedFile) { - return { ok: true } - }, - ) - const form = multipart('set_avatar', { user_id: 9 }, { - avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }), - }) - const r = await handleMultipartCall(form) - expect(r.status).toBe(200) - expect(r.headers['X-Mizan-Invalidate']).toContain('avatars') - }) -}) diff --git a/cores/mizan-python/pyproject.toml b/cores/mizan-python/pyproject.toml index 752a5c5..5259805 100644 --- a/cores/mizan-python/pyproject.toml +++ b/cores/mizan-python/pyproject.toml @@ -6,7 +6,6 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag requires-python = ">=3.10" dependencies = [ "PyJWT>=2.0", - "pydantic>=2.0", ] [project.optional-dependencies] diff --git a/cores/mizan-python/src/mizan_core/__init__.py b/cores/mizan-python/src/mizan_core/__init__.py index 0e996fb..e69de29 100644 --- a/cores/mizan-python/src/mizan_core/__init__.py +++ b/cores/mizan-python/src/mizan_core/__init__.py @@ -1,3 +0,0 @@ -from mizan_core.upload import File, Upload, UploadedFile, validate_upload - -__all__ = ["Upload", "File", "UploadedFile", "validate_upload"] diff --git a/cores/mizan-python/src/mizan_core/auth/__init__.py b/cores/mizan-python/src/mizan_core/auth/__init__.py deleted file mode 100644 index 2c4f237..0000000 --- a/cores/mizan-python/src/mizan_core/auth/__init__.py +++ /dev/null @@ -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", -] diff --git a/cores/mizan-python/src/mizan_core/auth/authenticate.py b/cores/mizan-python/src/mizan_core/auth/authenticate.py deleted file mode 100644 index 2bcd8b9..0000000 --- a/cores/mizan-python/src/mizan_core/auth/authenticate.py +++ /dev/null @@ -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 diff --git a/cores/mizan-python/src/mizan_core/auth/jwt.py b/cores/mizan-python/src/mizan_core/auth/jwt.py deleted file mode 100644 index 9bf531f..0000000 --- a/cores/mizan-python/src/mizan_core/auth/jwt.py +++ /dev/null @@ -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) diff --git a/cores/mizan-python/src/mizan_core/authguard.py b/cores/mizan-python/src/mizan_core/authguard.py deleted file mode 100644 index 9d760fb..0000000 --- a/cores/mizan-python/src/mizan_core/authguard.py +++ /dev/null @@ -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}") diff --git a/cores/mizan-python/src/mizan_core/client/function.py b/cores/mizan-python/src/mizan_core/client/function.py index 0ca1ba1..bcdda59 100644 --- a/cores/mizan-python/src/mizan_core/client/function.py +++ b/cores/mizan-python/src/mizan_core/client/function.py @@ -487,10 +487,8 @@ def _create_server_function( # Use function name directly name = fn.__name__ - # Extract type hints and signature. include_extras keeps `Annotated[...]` - # metadata (e.g. the `File(...)` marker on an Upload field) intact so it - # survives into the generated Input model. - hints = get_type_hints(fn, include_extras=True) + # Extract type hints and signature + hints = get_type_hints(fn) sig = inspect.signature(fn) params = list(sig.parameters.items()) diff --git a/cores/mizan-python/src/mizan_core/dispatch.py b/cores/mizan-python/src/mizan_core/dispatch.py deleted file mode 100644 index fb35f1d..0000000 --- a/cores/mizan-python/src/mizan_core/dispatch.py +++ /dev/null @@ -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 - ) diff --git a/cores/mizan-python/src/mizan_core/errors.py b/cores/mizan-python/src/mizan_core/errors.py deleted file mode 100644 index 6aa5f63..0000000 --- a/cores/mizan-python/src/mizan_core/errors.py +++ /dev/null @@ -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 diff --git a/cores/mizan-python/src/mizan_core/identity.py b/cores/mizan-python/src/mizan_core/identity.py deleted file mode 100644 index a9abfb9..0000000 --- a/cores/mizan-python/src/mizan_core/identity.py +++ /dev/null @@ -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 diff --git a/cores/mizan-python/src/mizan_core/invalidation.py b/cores/mizan-python/src/mizan_core/invalidation.py deleted file mode 100644 index 7f88960..0000000 --- a/cores/mizan-python/src/mizan_core/invalidation.py +++ /dev/null @@ -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) diff --git a/cores/mizan-python/src/mizan_core/ir.py b/cores/mizan-python/src/mizan_core/ir.py index 6e0c7d0..e9838a8 100644 --- a/cores/mizan-python/src/mizan_core/ir.py +++ b/cores/mizan-python/src/mizan_core/ir.py @@ -17,7 +17,6 @@ KDL grammar — locked contract: | list { } | optional { } | enum "" "" ... - | upload max-size=? { content-type "" ... } } ... } @@ -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.type_utils import extract_list_element, extract_optional -from mizan_core.upload import File, classify_upload __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) -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: """Emit a `struct { field ... }` block for a Pydantic model.""" with block.node("struct") as struct_block: @@ -289,11 +259,7 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s props["default"] = _kdl_value(default) 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) class _StructShape: diff --git a/cores/mizan-python/src/mizan_core/manifest.py b/cores/mizan-python/src/mizan_core/manifest.py deleted file mode 100644 index c0d4527..0000000 --- a/cores/mizan-python/src/mizan_core/manifest.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -Edge-manifest derivation — the AFI-common source of truth. - -The Edge manifest is a static JSON mapping contexts to URL patterns, params, and -cache/render policy. Mizan Edge reads it at deploy time to drive CDN cache -purging: when it receives `X-Mizan-Invalidate: user;user_id=5` it looks up -`user` in the manifest, resolves the page routes with the params, and purges -both the resolved URLs and the context endpoint. - -The manifest is *derived from the registry* — the same `@client` metadata every -adapter populates — so its derivation is AFI-common, not framework-bound. It -lives here in the core; each adapter exposes it (a callable, a CLI entry) over -its own surface. Django's `export_edge_manifest` command and the FastAPI -console entry both call `generate_edge_manifest`; there is one derivation. - -`render_strategy` is computed here too: a context whose params overlap -`USER_SCOPED_PARAMS` is `dynamic_cached` (per-user at the edge); one whose -params don't is `psr` (one shared pre-rendered artifact, re-rendered on -mutation). That single rule is what the `psr` capability checks for. -""" - -from __future__ import annotations - -import json -from typing import Any - -from mizan_core.registry import get_context_groups, get_function, get_all_functions - - -__all__ = [ - "USER_SCOPED_PARAMS", - "generate_edge_manifest", - "generate_edge_manifest_json", -] - - -# A context is per-user (and so must be `dynamic_cached` at the edge) when any of -# its params identifies a user. A context with no such param renders one shared -# artifact — `psr`. This set is the entire `render_strategy` decision. -USER_SCOPED_PARAMS: frozenset[str] = frozenset({"user_id", "user", "owner_id", "account_id"}) - - -def _input_param_names(fn_cls: Any) -> set[str]: - """The declared input field names of a registered function (empty if none).""" - input_cls = getattr(fn_cls, "Input", None) - if input_cls is not None and hasattr(input_cls, "model_fields"): - return set(input_cls.model_fields.keys()) - return set() - - -def generate_edge_manifest( - base_url: str = "/api/mizan", - view_urls: dict[str, list[str]] | None = None, -) -> dict[str, Any]: - """Derive the Edge manifest from the function registry. - - Args: - base_url: The Mizan API mount point (default ``/api/mizan``). - view_urls: Optional extra page routes per context for Edge to purge, - beyond the routes declared on view-path functions. - - Returns: - A JSON-serializable manifest: ``{"version", "contexts", "mutations"}``. - """ - groups = get_context_groups() - all_functions = get_all_functions() - - manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}} - - for ctx_name, fn_names in sorted(groups.items()): - param_names: set[str] = set() - functions_meta: list[dict[str, Any]] = [] - page_routes: list[str] = [] - - for fn_name in fn_names: - fn_cls = all_functions.get(fn_name) - if fn_cls is None: - continue - - param_names |= _input_param_names(fn_cls) - - meta = getattr(fn_cls, "_meta", {}) - route = meta.get("route") - view_path = meta.get("view_path") - - fn_entry: dict[str, Any] = { - "name": fn_name, - "path": "view" if view_path else "rpc", - } - if route: - fn_entry["route"] = route - fn_entry["methods"] = meta.get("methods", ["GET"]) - page_routes.append(route) - if meta.get("rev"): - fn_entry["rev"] = meta["rev"] - if meta.get("cache") is not None and meta.get("cache") is not True: - fn_entry["cache"] = meta["cache"] - functions_meta.append(fn_entry) - - user_scoped = any(p in USER_SCOPED_PARAMS for p in param_names) - - ctx_entry: dict[str, Any] = { - "functions": functions_meta, - "endpoints": [f"{base_url}/ctx/{ctx_name}/"], - "params": sorted(param_names), - "user_scoped": user_scoped, - "render_strategy": "dynamic_cached" if user_scoped else "psr", - } - - if page_routes: - ctx_entry["page_routes"] = page_routes - if view_urls and ctx_name in view_urls: - ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name]) - - manifest["contexts"][ctx_name] = ctx_entry - - for fn_name, fn_cls in sorted(all_functions.items()): - meta = getattr(fn_cls, "_meta", {}) - if not meta.get("affects"): - continue - - affected_contexts = list({a["name"] for a in meta["affects"]}) - mutation: dict[str, Any] = {"affects": affected_contexts} - - # Auto-scoped params — function params that match a param of an affected - # context. These are the keys Edge can resolve to scope the purge. - fn_params = _input_param_names(fn_cls) - if fn_params: - auto_scoped: list[str] = [] - for ctx_name in affected_contexts: - ctx_param_names: set[str] = set() - for ctx_fn_name in groups.get(ctx_name, []): - ctx_fn_cls = all_functions.get(ctx_fn_name) - if ctx_fn_cls is not None: - ctx_param_names |= _input_param_names(ctx_fn_cls) - for p in fn_params: - if p in ctx_param_names and p not in auto_scoped: - auto_scoped.append(p) - if auto_scoped: - mutation["auto_scoped_params"] = sorted(auto_scoped) - - if meta.get("private"): - mutation["private"] = True - if meta.get("route"): - mutation["route"] = meta["route"] - mutation["methods"] = meta.get("methods", ["POST"]) - - manifest["mutations"][fn_name] = mutation - - return manifest - - -def generate_edge_manifest_json( - base_url: str = "/api/mizan", - view_urls: dict[str, list[str]] | None = None, - indent: int | None = 2, -) -> str: - """JSON-serialize the Edge manifest (keys sorted for deterministic output).""" - return json.dumps( - generate_edge_manifest(base_url, view_urls), indent=indent, sort_keys=True - ) diff --git a/cores/mizan-python/src/mizan_core/registry.py b/cores/mizan-python/src/mizan_core/registry.py index 7e7ebf5..81e2119 100644 --- a/cores/mizan-python/src/mizan_core/registry.py +++ b/cores/mizan-python/src/mizan_core/registry.py @@ -1,15 +1,12 @@ """ Mizan core registry — function and composition registration with an -extension hook for the AFI-common capabilities that need their own -sub-registry (channels/WebSocket, forms, shapes) to plug into. +extension hook for backend-specific registries (channels, forms, etc.) +to plug into. -This is the framework-agnostic registry. The extension points -(channels, forms, websockets, shapes) are AFI-common: every adapter owes -a binding for each, on its own stack — Django Channels or a native -WebSocket route; Django Forms or Pydantic; django-readers or the project's -ORM. The capability is common; the binding is per-stack. Each adapter wires -its binding so the unified schema export sees it; an unwired one is a gap on -the capability-parity board (`tests/afi/`), not a framework-specific feature. +This is the framework-agnostic registry. Backends own their own +type-specific registries (channels in Django Channels, forms in Django +Forms, websockets in FastAPI, etc.) and register them as extensions +here so the unified schema export can include them. """ from __future__ import annotations diff --git a/cores/mizan-python/src/mizan_core/ssr/__init__.py b/cores/mizan-python/src/mizan_core/ssr/__init__.py deleted file mode 100644 index d1f7e42..0000000 --- a/cores/mizan-python/src/mizan_core/ssr/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -mizan_core.ssr — framework-agnostic server-side rendering. - -`SSRBridge` manages a persistent Bun subprocess that renders React components to -HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle; -adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's -`SSRRenderer`). -""" - -from mizan_core.ssr.bridge import RenderResult, SSRBridge - -__all__ = ["SSRBridge", "RenderResult"] diff --git a/cores/mizan-python/src/mizan_core/upload.py b/cores/mizan-python/src/mizan_core/upload.py deleted file mode 100644 index 3cac300..0000000 --- a/cores/mizan-python/src/mizan_core/upload.py +++ /dev/null @@ -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 diff --git a/cores/mizan-python/tests/test_dispatch_core.py b/cores/mizan-python/tests/test_dispatch_core.py deleted file mode 100644 index eb59b5e..0000000 --- a/cores/mizan-python/tests/test_dispatch_core.py +++ /dev/null @@ -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() diff --git a/cores/mizan-rust-macros/src/function.rs b/cores/mizan-rust-macros/src/function.rs index 6247b82..45fa73f 100644 --- a/cores/mizan-rust-macros/src/function.rs +++ b/cores/mizan-rust-macros/src/function.rs @@ -26,15 +26,6 @@ pub struct FunctionArgs { pub merge: Vec, pub websocket: bool, pub private: bool, - /// `auth = "required" | "staff" | "superuser"` (or bare `auth` ⇒ - /// "required") — the `@client(auth=...)` guard. Bare-true and the string - /// `"required"` both mean "must be authenticated". - pub auth: Option, - /// `form_name = "..."` + `form_role = "schema"|"validate"|"submit"` — the - /// Forms binding's per-endpoint metadata, mirroring the Django form - /// `_meta` keys. Carried into the IR (`is-form`/`form-name`/`form-role`). - pub form_name: Option, - pub form_role: Option, } impl FunctionArgs { @@ -54,16 +45,10 @@ impl FunctionArgs { out.affects = collect_paths(&nv.value)?; } else if nv.path.is_ident("merge") { out.merge = collect_paths(&nv.value)?; - } else if nv.path.is_ident("auth") { - out.auth = Some(expect_str(&nv.value)?); - } else if nv.path.is_ident("form_name") { - out.form_name = Some(expect_str(&nv.value)?); - } else if nv.path.is_ident("form_role") { - out.form_role = Some(expect_str(&nv.value)?); } else { return Err(syn::Error::new_spanned( nv.path, - "unknown attribute key; expected one of: context, affects, merge, auth, form_name, form_role", + "unknown attribute key; expected one of: context, affects, merge", )); } } @@ -72,12 +57,10 @@ impl FunctionArgs { out.websocket = true; } else if p.is_ident("private") { out.private = true; - } else if p.is_ident("auth") { - out.auth = Some("required".to_string()); } else { return Err(syn::Error::new_spanned( p, - "unknown flag; expected `websocket`, `private`, or `auth`", + "unknown flag; expected `websocket` or `private`", )); } } @@ -116,21 +99,6 @@ fn expect_path(expr: &Expr) -> syn::Result { } } -fn expect_str(expr: &Expr) -> syn::Result { - if let Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) = expr - { - Ok(s.value()) - } else { - Err(syn::Error::new_spanned( - expr, - "expected a string literal (e.g. `\"staff\"`)", - )) - } -} - fn collect_paths(expr: &Expr) -> syn::Result> { match expr { Expr::Path(_) => Ok(vec![expect_path(expr)?]), @@ -215,11 +183,7 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { }); } quote! { - // The synthetic Input is only ever *deserialized* (from the call's - // JSON args by the dispatch wrapper); it is never serialized, so it - // derives `Deserialize` only. Dropping `Serialize` lets binary - // field types like `Upload` (deserialize-only) participate. - #[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Deserialize)] + #[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)] pub struct #input_type_ident { #(#field_defs)* } @@ -389,20 +353,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { let output_nullable = analysis.nullable; let private = args.private; - let auth_value = match &args.auth { - Some(a) => quote! { ::std::option::Option::Some(#a) }, - None => quote! { ::std::option::Option::None }, - }; - let is_form = args.form_name.is_some() || args.form_role.is_some(); - let form_name_value = match &args.form_name { - Some(n) => quote! { ::std::option::Option::Some(#n) }, - None => quote! { ::std::option::Option::None }, - }; - let form_role_value = match &args.form_role { - Some(r) => quote! { ::std::option::Option::Some(#r) }, - None => quote! { ::std::option::Option::None }, - }; - let dispatch_body = build_dispatch( &item, &input_args, @@ -439,10 +389,6 @@ pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { fn merge(&self) -> &'static [&'static str] { #merge_static } fn transport(&self) -> ::mizan_core::Transport { #transport_value } fn private(&self) -> bool { #private } - fn auth(&self) -> ::std::option::Option<&'static str> { #auth_value } - fn is_form(&self) -> bool { #is_form } - fn form_name(&self) -> ::std::option::Option<&'static str> { #form_name_value } - fn form_role(&self) -> ::std::option::Option<&'static str> { #form_role_value } fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static } fn dispatch<'a>( diff --git a/cores/mizan-rust-macros/src/shape.rs b/cores/mizan-rust-macros/src/shape.rs index 35756b0..a3b94a6 100644 --- a/cores/mizan-rust-macros/src/shape.rs +++ b/cores/mizan-rust-macros/src/shape.rs @@ -105,15 +105,6 @@ pub fn type_shape_expr(ty: &Type) -> TokenStream { if let Some(p) = primitive_of(ty) { return quote! { ::mizan_core::TypeShape::Primitive(#p) }; } - if is_upload(ty) { - // An `Upload`-typed field emits the IR `upload` type-child rather than - // a `ref`, matching the Python emitter. Constraints (`max-size`, - // `content-type`) aren't carried in this baseline — an unconstrained - // upload — but the wire/IR shape is the recognized `upload` node. - return quote! { - ::mizan_core::TypeShape::Upload { max_size: ::std::option::Option::None, content_types: &[] } - }; - } // Fallback: assume a user-defined struct/enum implementing MizanType. // The Ref name comes from `::TYPE_NAME` (associated const). quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::TYPE_NAME) } @@ -158,19 +149,6 @@ pub fn unwrap_btreemap_value(ty: &Type) -> Option { type_args.next() } -/// True if `ty` names the `mizan_core::Upload` marker (by its last path -/// segment) — the binary file-input type. -pub fn is_upload(ty: &Type) -> bool { - match ty { - Type::Path(TypePath { qself: None, path }) => path - .segments - .last() - .map(|s| s.ident == "Upload") - .unwrap_or(false), - _ => false, - } -} - /// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a /// known primitive scalar. pub fn primitive_of(ty: &Type) -> Option { diff --git a/cores/mizan-rust/Cargo.lock b/cores/mizan-rust/Cargo.lock index 469a603..a2db134 100644 --- a/cores/mizan-rust/Cargo.lock +++ b/cores/mizan-rust/Cargo.lock @@ -13,82 +13,12 @@ dependencies = [ "syn", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "indoc" version = "2.0.7" @@ -104,12 +34,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - [[package]] name = "linkme" version = "0.3.36" @@ -141,14 +65,11 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", - "base64", - "hmac", "indoc", "linkme", "mizan-macros", "serde", "serde_json", - "sha2", ] [[package]] @@ -228,23 +149,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -256,24 +160,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "zmij" version = "1.0.21" diff --git a/cores/mizan-rust/Cargo.toml b/cores/mizan-rust/Cargo.toml index ae3e5ec..9ee85bb 100644 --- a/cores/mizan-rust/Cargo.toml +++ b/cores/mizan-rust/Cargo.toml @@ -11,9 +11,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" mizan-macros = { path = "../mizan-rust-macros" } -hmac = "0.12" -sha2 = "0.10" -base64 = "0.22" [dev-dependencies] indoc = "2" diff --git a/cores/mizan-rust/src/auth.rs b/cores/mizan-rust/src/auth.rs deleted file mode 100644 index b65dedf..0000000 --- a/cores/mizan-rust/src/auth.rs +++ /dev/null @@ -1,552 +0,0 @@ -//! JWT + MWT — HS256 mint and verify, byte-pinned to the Python core. -//! -//! Pinned references: -//! * JWT → `cores/mizan-python/src/mizan_core/auth/jwt.py` -//! * MWT → `cores/mizan-python/src/mizan_core/mwt.py` -//! -//! These are RFC 7519 JWTs over HMAC-SHA256. Byte-identical output to PyJWT -//! 2.x requires reproducing its exact serialization, which a generic JWT crate -//! does not expose: -//! -//! * the JOSE **header** keys are emitted in **sorted** order with compact -//! `(",", ":")` separators — `{"alg":"HS256","typ":"JWT"}`, or with a -//! `kid`, `{"alg":"HS256","kid":"v1","typ":"JWT"}`; -//! * the **payload** keys are emitted in **insertion** order (PyJWT does not -//! sort the claims) with the same compact separators; -//! * both segments are base64url-encoded **without padding**. -//! -//! So a mint here builds each segment's bytes deliberately (sorted header, -//! ordered claims) and signs `header.payload`. `tests/token_pin.rs` pins the -//! exact tokens against the Python reference for fixed inputs. - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use hmac::{Hmac, Mac}; -use sha2::Sha256; - -type HmacSha256 = Hmac; - -/// Current unix time in seconds — the `now` adapters pass to mint/verify when -/// they aren't pinning a fixed clock (tests inject a fixed value for byte -/// determinism). -pub fn now_unix() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn b64url(bytes: &[u8]) -> String { - URL_SAFE_NO_PAD.encode(bytes) -} - -fn b64url_decode(s: &str) -> Option> { - URL_SAFE_NO_PAD.decode(s).ok() -} - -fn sign(secret: &str, signing_input: &str) -> String { - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC any key length"); - mac.update(signing_input.as_bytes()); - b64url(&mac.finalize().into_bytes()) -} - -/// Build a JOSE header for HS256 with optional `kid`, keys in sorted order -/// (`alg` < `kid` < `typ`) and compact separators — byte-identical to PyJWT. -fn header_json(kid: Option<&str>) -> String { - match kid { - Some(kid) => format!( - "{{\"alg\":\"HS256\",\"kid\":{},\"typ\":\"JWT\"}}", - json_str(kid) - ), - None => "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(), - } -} - -/// Encode one JSON string literal byte-for-byte with PyJWT's serializer, -/// which is `json.dumps` with the default `ensure_ascii=True`: short escapes -/// for `"`, `\`, `\b\f\n\r\t`, and `\uXXXX` for the rest of the C0 range and -/// every non-ASCII code point (surrogate pairs above the BMP). -fn json_str(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{08}' => out.push_str("\\b"), - '\u{0c}' => out.push_str("\\f"), - c if (c as u32) < 0x20 || (c as u32) > 0x7e => { - let mut buf = [0u16; 2]; - for unit in c.encode_utf16(&mut buf) { - out.push_str(&format!("\\u{unit:04x}")); - } - } - c => out.push(c), - } - } - out.push('"'); - out -} - -fn json_bool(b: bool) -> &'static str { - if b { - "true" - } else { - "false" - } -} - -/// Mint `header.payload.signature` from a pre-serialized payload body. The -/// payload bytes are authored by the caller so claim ordering is under exact -/// control (PyJWT preserves insertion order). -fn encode(secret: &str, kid: Option<&str>, payload_json: &str) -> String { - let header = b64url(header_json(kid).as_bytes()); - let payload = b64url(payload_json.as_bytes()); - let signing_input = format!("{header}.{payload}"); - let sig = sign(secret, &signing_input); - format!("{signing_input}.{sig}") -} - -/// Verify the HS256 signature over `header.payload` and return the decoded -/// payload bytes. Constant-time-ish: recompute and compare the signature. -fn verify_signature(secret: &str, token: &str) -> Option> { - let mut parts = token.splitn(3, '.'); - let header_b64 = parts.next()?; - let payload_b64 = parts.next()?; - let sig_b64 = parts.next()?; - if parts.next().is_some() { - return None; - } - let signing_input = format!("{header_b64}.{payload_b64}"); - let expected = sign(secret, &signing_input); - // base64url of HMAC is fixed-length; a direct compare is adequate here and - // matches the reference's PyJWT-side verification semantics. - if !ct_eq(expected.as_bytes(), sig_b64.as_bytes()) { - return None; - } - b64url_decode(payload_b64) -} - -fn ct_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut diff = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - diff |= x ^ y; - } - diff == 0 -} - -/// Read the `kid` claim from the (unverified) JOSE header — needed before -/// signature verification to mirror `decode_mwt`'s `get_unverified_header`. -fn unverified_kid(token: &str) -> Option { - let header_b64 = token.split('.').next()?; - let bytes = b64url_decode(header_b64)?; - let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - v.get("kid") - .and_then(|k| k.as_str()) - .map(|s| s.to_string()) -} - -// ─── JWT ────────────────────────────────────────────────────────────────── - -/// JWT signing/verification config — Rust analog of `JWTConfig`. HS256 only -/// here (the byte-pinned algorithm); `private_key` doubles as the verify key. -#[derive(Debug, Clone)] -pub struct JwtConfig { - pub secret: String, - pub access_ttl: i64, - pub refresh_ttl: i64, -} - -impl JwtConfig { - pub fn new(secret: impl Into) -> Self { - Self { - secret: secret.into(), - access_ttl: 300, - refresh_ttl: 604_800, - } - } -} - -/// Decoded JWT claims — Rust analog of `TokenPayload`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct JwtPayload { - pub sub: String, - pub sid: String, - pub staff: bool, - pub superuser: bool, - pub token_type: String, - pub iat: i64, - pub exp: i64, -} - -/// Build the JWT claims body in PyJWT-insertion order: sub, sid, staff, super, -/// type, iat, exp. (Matches `jwt.py::_mint`.) -fn jwt_payload_json( - sub: &str, - sid: &str, - staff: bool, - superuser: bool, - token_type: &str, - iat: i64, - exp: i64, -) -> String { - format!( - "{{\"sub\":{},\"sid\":{},\"staff\":{},\"super\":{},\"type\":{},\"iat\":{},\"exp\":{}}}", - json_str(sub), - json_str(sid), - json_bool(staff), - json_bool(superuser), - json_str(token_type), - iat, - exp, - ) -} - -#[allow(clippy::too_many_arguments)] -fn mint_jwt( - cfg: &JwtConfig, - sub: &str, - sid: &str, - token_type: &str, - ttl: i64, - staff: bool, - superuser: bool, - now: i64, -) -> String { - let payload = jwt_payload_json(sub, sid, staff, superuser, token_type, now, now + ttl); - encode(&cfg.secret, None, &payload) -} - -/// Mint an access token. `now` is unix-seconds (injected for determinism). -pub fn create_access_token( - cfg: &JwtConfig, - sub: &str, - sid: &str, - staff: bool, - superuser: bool, - now: i64, -) -> String { - mint_jwt(cfg, sub, sid, "access", cfg.access_ttl, staff, superuser, now) -} - -/// Mint a refresh token. -pub fn create_refresh_token( - cfg: &JwtConfig, - sub: &str, - sid: &str, - staff: bool, - superuser: bool, - now: i64, -) -> String { - mint_jwt( - cfg, - sub, - sid, - "refresh", - cfg.refresh_ttl, - staff, - superuser, - now, - ) -} - -/// Decode + validate a JWT. `None` on a bad signature, malformed token, -/// expiry (against `now`), or a `type` mismatch. Mirrors `decode_token`. -pub fn decode_jwt( - token: &str, - cfg: &JwtConfig, - expected_type: Option<&str>, - now: i64, -) -> Option { - let payload_bytes = verify_signature(&cfg.secret, token)?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - let exp = v.get("exp")?.as_i64()?; - if now >= exp { - return None; - } - let token_type = v.get("type")?.as_str()?.to_string(); - if let Some(want) = expected_type { - if token_type != want { - return None; - } - } - Some(JwtPayload { - sub: v.get("sub")?.as_str()?.to_string(), - sid: v.get("sid")?.as_str()?.to_string(), - staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false), - superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false), - token_type, - iat: v.get("iat")?.as_i64()?, - exp, - }) -} - -// ─── MWT ──────────────────────────────────────────────────────────────────── - -/// Decoded MWT claims — Rust analog of `MWTPayload`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MwtPayload { - pub sub: String, - pub staff: bool, - pub superuser: bool, - pub pkey: String, - pub kid: String, - pub aud: String, - pub iat: i64, - pub exp: i64, -} - -/// Compute the permission-state hash — full SHA-256 hex over -/// `"{staff}:{super}:{sorted,comma-joined perms}"`. Matches -/// `mwt.py::compute_permission_key` byte-for-byte. -pub fn compute_permission_key(staff: bool, superuser: bool, perms: &[String]) -> String { - use sha2::Digest; - let mut sorted: Vec<&String> = perms.iter().collect(); - sorted.sort(); - let staff_c = if staff { "1" } else { "0" }; - let super_c = if superuser { "1" } else { "0" }; - let joined: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect(); - let blob = format!("{staff_c}:{super_c}:{}", joined.join(",")); - let digest = Sha256::digest(blob.as_bytes()); - digest.iter().map(|b| format!("{b:02x}")).collect() -} - -/// Build the MWT claims body in `create_mwt` insertion order: sub, staff, -/// super, pkey, aud, iat, nbf, exp. -#[allow(clippy::too_many_arguments)] -fn mwt_payload_json( - sub: &str, - staff: bool, - superuser: bool, - pkey: &str, - aud: &str, - iat: i64, - nbf: i64, - exp: i64, -) -> String { - format!( - "{{\"sub\":{},\"staff\":{},\"super\":{},\"pkey\":{},\"aud\":{},\"iat\":{},\"nbf\":{},\"exp\":{}}}", - json_str(sub), - json_bool(staff), - json_bool(superuser), - json_str(pkey), - json_str(aud), - iat, - nbf, - exp, - ) -} - -/// Mint an MWT from already-resolved identity fields. `pkey` is the permission -/// hash (see `compute_permission_key`); `now` is unix-seconds. -#[allow(clippy::too_many_arguments)] -pub fn create_mwt( - secret: &str, - sub: &str, - staff: bool, - superuser: bool, - pkey: &str, - ttl: i64, - audience: &str, - kid: &str, - now: i64, -) -> String { - let payload = mwt_payload_json(sub, staff, superuser, pkey, audience, now, now, now + ttl); - encode(secret, Some(kid), &payload) -} - -/// Decode + validate an MWT. `None` on bad signature, malformed token, expiry, -/// not-yet-valid (`nbf`), or audience mismatch. Mirrors `decode_mwt`. -pub fn decode_mwt(token: &str, secret: &str, audience: &str, now: i64) -> Option { - let kid = unverified_kid(token).unwrap_or_else(|| "v1".to_string()); - let payload_bytes = verify_signature(secret, token)?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - - let exp = v.get("exp")?.as_i64()?; - if now >= exp { - return None; - } - if let Some(nbf) = v.get("nbf").and_then(|n| n.as_i64()) { - if now < nbf { - return None; - } - } - let aud = v.get("aud").and_then(|a| a.as_str()).unwrap_or(""); - if aud != audience { - return None; - } - - Some(MwtPayload { - sub: v.get("sub")?.as_str()?.to_string(), - staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false), - superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false), - pkey: v - .get("pkey") - .and_then(|p| p.as_str()) - .unwrap_or("") - .to_string(), - kid, - aud: audience.to_string(), - iat: v.get("iat")?.as_i64()?, - exp, - }) -} - -// ─── Identity + auth-guard enforcement ─────────────────────────────────────── - -/// The identity a token resolves to — Rust analog of `Identity`. `None` -/// (anonymous) and `Invalid` (a present-but-bad token) are distinct: the -/// adapter must REJECT on `Invalid`, never silently downgrade to anonymous. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Identity { - pub user_id: String, - pub is_staff: bool, - pub is_superuser: bool, -} - -impl From<&JwtPayload> for Identity { - fn from(p: &JwtPayload) -> Self { - Self { - user_id: p.sub.clone(), - is_staff: p.staff, - is_superuser: p.superuser, - } - } -} - -impl From<&MwtPayload> for Identity { - fn from(p: &MwtPayload) -> Self { - Self { - user_id: p.sub.clone(), - is_staff: p.staff, - is_superuser: p.superuser, - } - } -} - -/// Result of resolving identity from request headers. Mirrors the Python -/// `Identity | INVALID | None` contract. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthOutcome { - /// A valid token resolved to this identity. - Authenticated(Identity), - /// No token was offered — the adapter may fall back to session identity. - Anonymous, - /// A token was present but failed validation — the adapter MUST reject. - Invalid, -} - -/// Auth config carried by the adapter — JWT and/or MWT secrets. Either may be -/// absent; a token type with no configured secret is ignored. Mirrors -/// `AuthConfig`. -#[derive(Debug, Clone, Default)] -pub struct AuthConfig { - pub jwt: Option, - pub mwt_secret: Option, - pub mwt_audience: String, -} - -impl AuthConfig { - pub fn new() -> Self { - Self { - jwt: None, - mwt_secret: None, - mwt_audience: "mizan".to_string(), - } - } -} - -/// Resolve identity from `X-Mizan-Token` (MWT) then `Authorization: Bearer` -/// (JWT). Header lookup is case-sensitive on the names the adapter passes in; -/// pass both casings or normalize upstream. Mirrors `authenticate`. -pub fn authenticate( - mwt_header: Option<&str>, - bearer_header: Option<&str>, - config: &AuthConfig, - now: i64, -) -> AuthOutcome { - if let (Some(mwt), Some(secret)) = (mwt_header, config.mwt_secret.as_deref()) { - if !mwt.is_empty() { - return match decode_mwt(mwt, secret, &config.mwt_audience, now) { - Some(p) => AuthOutcome::Authenticated(Identity::from(&p)), - None => AuthOutcome::Invalid, - }; - } - } - - if let (Some(bearer), Some(jwt_cfg)) = (bearer_header, config.jwt.as_ref()) { - if let Some(token) = bearer.strip_prefix("Bearer ") { - return match decode_jwt(token, jwt_cfg, Some("access"), now) { - Some(p) => AuthOutcome::Authenticated(Identity::from(&p)), - None => AuthOutcome::Invalid, - }; - } - } - - AuthOutcome::Anonymous -} - -/// The `@client(auth=...)` requirement a function declares. `Callable` carries -/// the host's own predicate — the adapter resolves it; the core stays free of -/// the native request. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthRequirement { - None, - Required, - Staff, - Superuser, -} - -impl AuthRequirement { - /// Parse the IR/`FunctionSpec` auth string into a requirement. - /// `"required" | "staff" | "superuser"` → the matching variant; anything - /// else (including the absence of an `auth=`) → `None`. - pub fn from_str_opt(s: Option<&str>) -> Self { - match s { - Some("required") | Some("true") => AuthRequirement::Required, - Some("staff") => AuthRequirement::Staff, - Some("superuser") => AuthRequirement::Superuser, - _ => AuthRequirement::None, - } - } -} - -/// Enforce a function's `auth=` against the resolved identity. `Ok(())` to -/// proceed; `Err(MizanError)` (`Unauthorized`/`Forbidden`) to reject. Mirrors -/// `authguard.enforce_auth`. -pub fn enforce_auth( - identity: Option<&Identity>, - requirement: &AuthRequirement, -) -> Result<(), crate::runtime::MizanError> { - use crate::runtime::MizanError; - if matches!(requirement, AuthRequirement::None) { - return Ok(()); - } - let ident = match identity { - Some(i) => i, - None => return Err(MizanError::Unauthorized("Authentication required".into())), - }; - match requirement { - AuthRequirement::None | AuthRequirement::Required => Ok(()), - AuthRequirement::Staff => { - if ident.is_staff { - Ok(()) - } else { - Err(MizanError::Forbidden("Staff access required".into())) - } - } - AuthRequirement::Superuser => { - if ident.is_superuser { - Ok(()) - } else { - Err(MizanError::Forbidden("Superuser access required".into())) - } - } - } -} diff --git a/cores/mizan-rust/src/cache.rs b/cores/mizan-rust/src/cache.rs deleted file mode 100644 index 0fad3bb..0000000 --- a/cores/mizan-rust/src/cache.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Origin-side cache: HMAC-SHA256 key derivation + a pluggable backend. -//! -//! Byte-pinned to `cores/mizan-python/src/mizan_core/cache/keys.py`. The HMAC -//! message is the JSON-canonical form `{"c":ctx,"p":{sorted params},"r":rev}` -//! (with optional `"u":user_id`), emitted with Python's `json.dumps(..., -//! sort_keys=True, separators=(",", ":"))` byte layout: keys sorted, no -//! whitespace. Every Mizan adapter must produce the identical key for -//! identical inputs — `tests/cache_keys_pin.rs` pins this against the Python -//! reference and the committed cross-language vectors. - -use hmac::{Hmac, Mac}; -use serde_json::Value; -use sha2::Sha256; -use std::collections::BTreeMap; - -/// Context prefix for broad purge (SCAN pattern), mirroring Python's -/// `CONTEXT_KEY_PREFIX`. -pub const CONTEXT_KEY_PREFIX: &str = "ctx:"; - -type HmacSha256 = Hmac; - -/// Normalize a param value to its cross-language-stable string form. -/// -/// Python `str(True)` is `"True"` but JS `String(true)` is `"true"`; the -/// reference picks the JSON-native spelling. Numbers and strings stringify -/// directly. This must match `keys.py::_normalize` exactly. -fn normalize(v: &Value) -> String { - match v { - Value::Bool(true) => "true".to_string(), - Value::Bool(false) => "false".to_string(), - Value::Null => "null".to_string(), - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - // Arrays/objects have no scalar param meaning; fall back to the JSON - // text, matching Python's `str(v)` catch-all for non-scalars. - other => other.to_string(), - } -} - -/// JSON-escape a string into `out` byte-for-byte with Python's -/// `json.dumps(..., ensure_ascii=True)`: the short escapes for `"`, `\`, -/// `\b\f\n\r\t`, `\uXXXX` for the rest of the C0 control range, and — because -/// the reference leaves `ensure_ascii` at its default `True` — `\uXXXX` for -/// every non-ASCII code point, encoded as a UTF-16 surrogate pair when the -/// code point is above the BMP (e.g. `😀` → `😀`). -fn push_json_string(out: &mut String, s: &str) { - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{08}' => out.push_str("\\b"), - '\u{0c}' => out.push_str("\\f"), - c if (c as u32) < 0x20 || (c as u32) > 0x7e => { - let mut buf = [0u16; 2]; - for unit in c.encode_utf16(&mut buf) { - out.push_str(&format!("\\u{unit:04x}")); - } - } - c => out.push(c), - } - } - out.push('"'); -} - -/// Build the exact HMAC message bytes: `{"c":...,"p":{...},"r":...}` with an -/// optional `"u":...`. Keys are emitted in sorted order (c, p, r, u) and the -/// `p` object's keys are sorted too — equivalent to `sort_keys=True`. -fn canonical_message( - context: &str, - params: &BTreeMap, - user_id: Option<&str>, - rev: i64, -) -> String { - let mut msg = String::new(); - msg.push('{'); - - // "c" - msg.push_str("\"c\":"); - push_json_string(&mut msg, context); - - // "p" — object of normalized, sorted params (BTreeMap iterates sorted). - msg.push_str(",\"p\":{"); - for (i, (k, v)) in params.iter().enumerate() { - if i > 0 { - msg.push(','); - } - push_json_string(&mut msg, k); - msg.push(':'); - push_json_string(&mut msg, &normalize(v)); - } - msg.push('}'); - - // "r" - msg.push_str(",\"r\":"); - msg.push_str(&rev.to_string()); - - // "u" (optional) — sorts after "r". - if let Some(uid) = user_id { - msg.push_str(",\"u\":"); - push_json_string(&mut msg, uid); - } - - msg.push('}'); - msg -} - -/// Derive a deterministic HMAC-SHA256 cache key. -/// -/// Returns `ctx:{context}:{hmac_hex}` so broad purge can SCAN by the prefix -/// `ctx:{context}:*`. Byte-identical to the Python/TS reference for identical -/// inputs. -pub fn derive_cache_key( - secret: &str, - context: &str, - params: &BTreeMap, - user_id: Option<&str>, - rev: i64, -) -> String { - let message = canonical_message(context, params, user_id, rev); - let mut mac = - HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length"); - mac.update(message.as_bytes()); - let digest = mac.finalize().into_bytes(); - let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); - format!("{CONTEXT_KEY_PREFIX}{context}:{hex}") -} - -/// Pluggable origin cache store. The HTTP adapter injects a backend (memory -/// for tests, Redis in production); dispatch reads/writes through it. -pub trait CacheBackend: Send + Sync { - fn get(&self, key: &str) -> Option>; - fn set(&self, key: &str, value: Vec); - fn delete(&self, key: &str); - /// Delete every key beginning with `prefix` (broad purge). - fn delete_by_prefix(&self, prefix: &str); -} - -/// In-memory `CacheBackend` for tests and single-process deployments. Mirrors -/// the Python `MemoryCache` — a dict guarded by a lock, no persistence. -#[derive(Default)] -pub struct MemoryCache { - store: std::sync::Mutex>>, -} - -impl MemoryCache { - pub fn new() -> Self { - Self::default() - } -} - -impl CacheBackend for MemoryCache { - fn get(&self, key: &str) -> Option> { - self.store.lock().unwrap().get(key).cloned() - } - - fn set(&self, key: &str, value: Vec) { - self.store.lock().unwrap().insert(key.to_string(), value); - } - - fn delete(&self, key: &str) { - self.store.lock().unwrap().remove(key); - } - - fn delete_by_prefix(&self, prefix: &str) { - self.store - .lock() - .unwrap() - .retain(|k, _| !k.starts_with(prefix)); - } -} - -/// Origin-side cache orchestrator — backend + secret injected by the adapter -/// (the config seam). Mirrors Python's `CacheOrchestrator`: disabled (a no-op) -/// until both a backend and a secret are present. -pub struct CacheOrchestrator { - backend: Option>, - secret: Option, -} - -impl CacheOrchestrator { - pub fn new(backend: Option>, secret: Option) -> Self { - Self { backend, secret } - } - - /// A disabled orchestrator — every op is a no-op. Used by stateless apps. - pub fn disabled() -> Self { - Self { - backend: None, - secret: None, - } - } - - pub fn enabled(&self) -> bool { - self.backend.is_some() && self.secret.as_deref().is_some_and(|s| !s.is_empty()) - } - - fn key( - &self, - context: &str, - params: &BTreeMap, - user_id: Option<&str>, - rev: i64, - ) -> Option { - let secret = self.secret.as_deref()?; - Some(derive_cache_key(secret, context, params, user_id, rev)) - } - - pub fn get( - &self, - context: &str, - params: &BTreeMap, - user_id: Option<&str>, - rev: i64, - ) -> Option> { - if !self.enabled() { - return None; - } - let backend = self.backend.as_ref()?; - let key = self.key(context, params, user_id, rev)?; - backend.get(&key) - } - - pub fn put( - &self, - context: &str, - params: &BTreeMap, - value: Vec, - user_id: Option<&str>, - rev: i64, - ) { - if !self.enabled() { - return; - } - if let (Some(backend), Some(key)) = - (self.backend.as_ref(), self.key(context, params, user_id, rev)) - { - backend.set(&key, value); - } - } - - /// Purge the cache entries named by an invalidation list. A scoped entry - /// (`ScopedContext`) deletes its single derived key; a bare context purges - /// by prefix — exactly Python's `CacheOrchestrator.purge`. - pub fn purge(&self, invalidate: &[crate::runtime::InvalidationTarget], user_id: Option<&str>) { - if !self.enabled() { - return; - } - let backend = match self.backend.as_ref() { - Some(b) => b, - None => return, - }; - for entry in invalidate { - match entry { - crate::runtime::InvalidationTarget::Context(ctx) - | crate::runtime::InvalidationTarget::Function(ctx) => { - backend.delete_by_prefix(&format!("{CONTEXT_KEY_PREFIX}{ctx}:")); - } - crate::runtime::InvalidationTarget::ScopedContext { context, params } => { - let params_tree: BTreeMap = - params.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - if let Some(key) = self.key(context, ¶ms_tree, user_id, 0) { - backend.delete(&key); - } - } - } - } - } -} diff --git a/cores/mizan-rust/src/ir.rs b/cores/mizan-rust/src/ir.rs index c64316c..108ab52 100644 --- a/cores/mizan-rust/src/ir.rs +++ b/cores/mizan-rust/src/ir.rs @@ -26,14 +26,6 @@ pub enum TypeShape { Optional(Box), Enum(Vec<&'static str>), Union(Vec), - /// An `Upload`-typed field — a binary file input. Emits the IR `upload` - /// type-child (matching `cores/mizan-python`'s `_emit_upload_node`), with - /// optional declarative `max-size` / `content-type` constraints. `None`s - /// mean an unconstrained upload. - Upload { - max_size: Option, - content_types: &'static [&'static str], - }, } #[derive(Debug, Clone, Copy)] diff --git a/cores/mizan-rust/src/kdl.rs b/cores/mizan-rust/src/kdl.rs index ee07ff3..c67dcd1 100644 --- a/cores/mizan-rust/src/kdl.rs +++ b/cores/mizan-rust/src/kdl.rs @@ -160,29 +160,6 @@ impl<'a> Emitter<'a> { } self.close(indent); } - TypeShape::Upload { - max_size, - content_types, - } => { - // Match Python's `_emit_upload_node`: `max-size` is the bare - // integer (its `repr`); content-types become nested children; - // the unconstrained case is a bare `upload` leaf. - let mut header: Vec = vec!["upload".into()]; - if let Some(ms) = max_size { - header.push(format!("max-size={ms}")); - } - let header_refs: Vec<&str> = header.iter().map(String::as_str).collect(); - if content_types.is_empty() { - self.leaf(indent, &header_refs); - } else { - self.open(indent, &header_refs); - for ct in content_types.iter() { - let lit = kdl_string(ct); - self.leaf(indent + 1, &["content-type", &lit]); - } - self.close(indent); - } - } } } @@ -487,7 +464,7 @@ fn walk_shape_refs(shape: &TypeShape, visit: &mut F) { walk_shape_refs(b, visit); } } - TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {} + TypeShape::Primitive(_) | TypeShape::Enum(_) => {} } } diff --git a/cores/mizan-rust/src/lib.rs b/cores/mizan-rust/src/lib.rs index bc84c3d..ae0ab97 100644 --- a/cores/mizan-rust/src/lib.rs +++ b/cores/mizan-rust/src/lib.rs @@ -14,43 +14,25 @@ //! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at //! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`. -pub mod auth; -pub mod cache; pub mod graph_check; pub mod ir; pub mod kdl; -pub mod manifest; pub mod registry; pub mod runtime; -pub mod shapes; -pub mod ssr; pub mod traits; -pub mod upload; -pub use auth::{ - authenticate, compute_permission_key, create_access_token, create_mwt, create_refresh_token, - decode_jwt, decode_mwt, enforce_auth, now_unix, AuthConfig, AuthOutcome, AuthRequirement, - Identity, JwtConfig, JwtPayload, MwtPayload, -}; -pub use upload::Upload; -pub use cache::{ - derive_cache_key, CacheBackend, CacheOrchestrator, MemoryCache, CONTEXT_KEY_PREFIX, -}; pub use ir::{ AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, }; pub use kdl::{build_ir, snake_to_camel}; -pub use manifest::{generate_edge_manifest, generate_edge_manifest_json}; pub use registry::{ context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS, FUNCTIONS, TYPES, }; pub use runtime::{ - compute_invalidation, compute_merges, format_invalidate_header, InvalidationTarget, - MergeEntry, MizanError, RequestHandle, + compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError, + RequestHandle, }; -pub use shapes::{QueryProjection, ShapeField}; -pub use ssr::{SsrBridge, SsrError, WorkerCommand}; pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; // Re-export proc macros so consumers depend on one crate. diff --git a/cores/mizan-rust/src/manifest.rs b/cores/mizan-rust/src/manifest.rs deleted file mode 100644 index 895d367..0000000 --- a/cores/mizan-rust/src/manifest.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Edge manifest — the static JSON that Mizan Edge reads to configure CDN -//! cache rules + invalidation routing. -//! -//! Mirrors `backends/mizan-django/src/mizan/export/__init__.py`'s -//! `generate_edge_manifest`: a `{version, contexts, mutations}` document where -//! each context carries its functions, endpoints, params, `user_scoped`, and -//! `render_strategy` (the PSR axis), and each mutation carries its `affects` -//! and `auto_scoped_params`. Keys are emitted alphabetically (the Django -//! command serializes with `sort_keys=True`); `to_json_string` matches that. - -use crate::registry::{context_members, CONTEXTS, FUNCTIONS}; -use crate::traits::FunctionSpec; -use serde_json::{json, Map, Value}; -use std::collections::BTreeSet; - -/// Params that imply a user-scoped context → `render_strategy: -/// "dynamic_cached"`. Anything else renders as `"psr"`. Matches Python's -/// `_USER_SCOPED_PARAMS`. -const USER_SCOPED_PARAMS: [&str; 4] = ["user_id", "user", "owner_id", "account_id"]; - -/// Build the edge manifest as a `serde_json::Value`. `base_url` is the Mizan -/// mount point (default `/api/mizan`). -pub fn generate_edge_manifest(base_url: &str) -> Value { - let mut contexts = Map::new(); - - // Contexts, alphabetical by name (BTreeSet over the registered names). - let ctx_names: BTreeSet<&'static str> = CONTEXTS.iter().map(|c| c.name).collect(); - for ctx_name in &ctx_names { - let members = context_members(ctx_name); - if members.is_empty() { - continue; - } - - let mut param_names: BTreeSet<&'static str> = BTreeSet::new(); - let mut functions_meta: Vec = Vec::new(); - let mut page_routes: Vec = Vec::new(); - - for fn_spec in &members { - for p in fn_spec.input_params() { - param_names.insert(p.name); - } - // The Rust IR has no view-path/route metadata yet; every function - // is an RPC path. (`route`/`view_path` land with the view-path - // macro extension.) - functions_meta.push(json!({ "name": fn_spec.name(), "path": "rpc" })); - } - - let user_scoped = param_names - .iter() - .any(|p| USER_SCOPED_PARAMS.contains(p)); - - let mut ctx_entry = Map::new(); - ctx_entry.insert("functions".into(), Value::Array(functions_meta)); - ctx_entry.insert( - "endpoints".into(), - json!([format!("{base_url}/ctx/{ctx_name}/")]), - ); - ctx_entry.insert( - "params".into(), - Value::Array( - param_names - .iter() - .map(|p| Value::String((*p).to_string())) - .collect(), - ), - ); - ctx_entry.insert("user_scoped".into(), Value::Bool(user_scoped)); - ctx_entry.insert( - "render_strategy".into(), - Value::String( - if user_scoped { - "dynamic_cached" - } else { - "psr" - } - .to_string(), - ), - ); - if !page_routes.is_empty() { - page_routes.sort(); - ctx_entry.insert( - "page_routes".into(), - Value::Array(page_routes.into_iter().map(Value::String).collect()), - ); - } - - contexts.insert((*ctx_name).to_string(), Value::Object(ctx_entry)); - } - - // Mutations — every non-private function declaring `affects`, alphabetical. - let mut fns: Vec<&'static dyn FunctionSpec> = FUNCTIONS.iter().copied().collect(); - fns.sort_by_key(|f| f.name()); - - let mut mutations = Map::new(); - for fn_spec in &fns { - let affected: BTreeSet<&'static str> = fn_spec - .affects() - .iter() - .filter_map(|a| match a { - crate::ir::AffectTarget::Context(name) => Some(*name), - crate::ir::AffectTarget::Function { context, .. } => *context, - }) - .collect(); - if affected.is_empty() { - continue; - } - - let mut mutation = Map::new(); - mutation.insert( - "affects".into(), - Value::Array( - affected - .iter() - .map(|c| Value::String((*c).to_string())) - .collect(), - ), - ); - - // Auto-scoped params: this mutation's params that also name a param of - // an affected context. - let fn_params: BTreeSet<&'static str> = - fn_spec.input_params().iter().map(|p| p.name).collect(); - let mut auto_scoped: BTreeSet<&'static str> = BTreeSet::new(); - for ctx in &affected { - let mut ctx_params: BTreeSet<&'static str> = BTreeSet::new(); - for m in context_members(ctx) { - for p in m.input_params() { - ctx_params.insert(p.name); - } - } - for p in fn_params.intersection(&ctx_params) { - auto_scoped.insert(*p); - } - } - if !auto_scoped.is_empty() { - mutation.insert( - "auto_scoped_params".into(), - Value::Array( - auto_scoped - .iter() - .map(|p| Value::String((*p).to_string())) - .collect(), - ), - ); - } - - if fn_spec.private() { - mutation.insert("private".into(), Value::Bool(true)); - } - - mutations.insert(fn_spec.name().to_string(), Value::Object(mutation)); - } - - json!({ - "version": 1, - "contexts": Value::Object(contexts), - "mutations": Value::Object(mutations), - }) -} - -/// JSON-serialize the manifest with sorted keys (the Django command uses -/// `json.dumps(..., sort_keys=True)`); `indent` of 0 → compact. -pub fn generate_edge_manifest_json(base_url: &str, indent: usize) -> String { - let value = generate_edge_manifest(base_url); - let sorted = sort_value(&value); - if indent == 0 { - serde_json::to_string(&sorted).unwrap() - } else { - serde_json::to_string_pretty(&sorted).unwrap() - } -} - -/// Recursively re-key every object so serialization is sorted-key, matching -/// Python's `sort_keys=True`. (serde_json::Map preserves insertion order, so -/// we rebuild via BTreeMap ordering.) -fn sort_value(v: &Value) -> Value { - match v { - Value::Object(m) => { - let mut keys: Vec<&String> = m.keys().collect(); - keys.sort(); - let mut out = Map::new(); - for k in keys { - out.insert(k.clone(), sort_value(&m[k])); - } - Value::Object(out) - } - Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()), - other => other.clone(), - } -} diff --git a/cores/mizan-rust/src/runtime.rs b/cores/mizan-rust/src/runtime.rs index 5b388b7..c1a01fd 100644 --- a/cores/mizan-rust/src/runtime.rs +++ b/cores/mizan-rust/src/runtime.rs @@ -135,75 +135,6 @@ impl InvalidationTarget { } } -/// Percent-encode for the `X-Mizan-Invalidate` header, matching Python's -/// `urllib.parse.quote(str(v), safe='')`: the RFC 3986 unreserved set -/// (`A-Za-z0-9_.-~`) passes through; every other byte (of the UTF-8 encoding) -/// becomes `%XX` with **upper-case** hex. -fn url_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => { - out.push(b as char); - } - _ => out.push_str(&format!("%{b:02X}")), - } - } - out -} - -/// Render an invalidation value to a JSON-ish string for header param values. -/// Mirrors Python's `str(v)`: a JSON string yields its raw text; numbers and -/// booleans their literal spelling (`true`/`false`); other shapes their JSON. -fn header_value_str(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - Value::Null => "None".to_string(), - other => other.to_string(), - } -} - -/// Serialize a list of targets to the `X-Mizan-Invalidate` header value — -/// byte-for-byte with `cores/mizan-python`'s `format_invalidate_header`: -/// comma-separated contexts, semicolon-separated URL-encoded params per -/// context (params sorted by key). -/// -/// `[Context("user")]` → `user` -/// `[Context("user"), Context("notifications")]` → `user, notifications` -/// `[ScopedContext{user, {user_id:5}}]` → `user;user_id=5` -/// `[ScopedContext{search, {q:"hello world"}}]` → `search;q=hello%20world` -pub fn format_invalidate_header(targets: &[InvalidationTarget]) -> String { - let mut parts: Vec = Vec::new(); - for t in targets { - match t { - InvalidationTarget::Context(name) | InvalidationTarget::Function(name) => { - parts.push(name.clone()); - } - InvalidationTarget::ScopedContext { context, params } => { - if params.is_empty() { - parts.push(context.clone()); - } else { - // BTreeMap-sort the keys to match Python's `sorted(params.items())`. - let mut keys: Vec<&String> = params.keys().collect(); - keys.sort(); - let param_str = keys - .iter() - .map(|k| { - let v = ¶ms[*k]; - format!("{}={}", url_encode(k), url_encode(&header_value_str(v))) - }) - .collect::>() - .join(";"); - parts.push(format!("{context};{param_str}")); - } - } - } - } - parts.join(", ") -} - /// One entry in the response's `merge` array. Server-resolved slot — the /// kernel writes the value into `bundle[slot]` directly. #[derive(Debug, Clone)] diff --git a/cores/mizan-rust/src/shapes.rs b/cores/mizan-rust/src/shapes.rs deleted file mode 100644 index 14885bf..0000000 --- a/cores/mizan-rust/src/shapes.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Shapes — typed query projection over the registered type graph. -//! -//! The AFI-common capability is "given the typed shape a function returns, -//! derive the field projection a query layer should select" — the same role -//! django-readers plays on Django (a `Shape` declares fields + nested shapes, -//! and `_spec` is the projection handed to the ORM). The binding is per-ORM; -//! the *capability* — deriving the projection from the declared shape — is -//! shared, so it lives here in the core and each adapter rides it. -//! -//! A `QueryProjection` is computed from a registered named type's struct -//! shape: scalar fields become leaf selections, `Ref`-to-struct fields become -//! nested projections (recursively), lists/optionals unwrap to their element. -//! It is the typed, ORM-agnostic answer to "what columns/relations does this -//! response need?" — the dead-field-elimination the whole-stack story wants, -//! reached from the response type. - -use crate::ir::{NamedType, TypeShape}; -use crate::registry::TYPES; -use std::collections::BTreeMap; - -/// One selected field of a projection: a scalar leaf, or a nested projection -/// for a related struct. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ShapeField { - /// A scalar/primitive column. - Leaf(String), - /// A related struct, with its own projection. - Nested(String, QueryProjection), -} - -impl ShapeField { - pub fn name(&self) -> &str { - match self { - ShapeField::Leaf(n) | ShapeField::Nested(n, _) => n, - } - } -} - -/// A typed, ORM-agnostic field projection derived from a named struct type. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct QueryProjection { - /// The named type this projects (the struct's IR name). - pub type_name: String, - pub fields: Vec, -} - -impl QueryProjection { - /// The flat list of scalar leaf field names selected at this level. - pub fn leaf_names(&self) -> Vec<&str> { - self.fields - .iter() - .filter_map(|f| match f { - ShapeField::Leaf(n) => Some(n.as_str()), - _ => None, - }) - .collect() - } - - /// The nested relations selected at this level, name → sub-projection. - pub fn nested(&self) -> Vec<(&str, &QueryProjection)> { - self.fields - .iter() - .filter_map(|f| match f { - ShapeField::Nested(n, p) => Some((n.as_str(), p)), - _ => None, - }) - .collect() - } -} - -/// Build the registry's named-type table once (name → shape). -fn type_table() -> BTreeMap<&'static str, NamedType> { - let mut t = BTreeMap::new(); - for entry in TYPES { - t.insert(entry.name, (entry.shape_fn)()); - } - t -} - -/// Unwrap a `TypeShape` to the named struct it ultimately references, if any -/// — peeling `List`/`Optional`. Returns the referenced type name. -fn referenced_struct<'a>( - shape: &TypeShape, - table: &'a BTreeMap<&'static str, NamedType>, -) -> Option<&'a str> { - match shape { - TypeShape::Ref(name) => { - // Only treat it as nested if it resolves to a struct. - match table.get(name) { - Some(NamedType::Struct(_)) => Some(name), - _ => None, - } - } - TypeShape::List(inner) | TypeShape::Optional(inner) => referenced_struct(inner, table), - _ => None, - } -} - -/// Derive the projection for a registered named type by its IR name. `None` -/// if the name is absent or is not a struct. -pub fn project(type_name: &str) -> Option { - let table = type_table(); - project_inner(type_name, &table, &mut Vec::new()) -} - -fn project_inner( - type_name: &str, - table: &BTreeMap<&'static str, NamedType>, - stack: &mut Vec, -) -> Option { - let body = table.get(type_name)?; - let fields = match body { - NamedType::Struct(fields) => fields, - _ => return None, - }; - - // Guard against recursive types (self-referential shapes): a name already - // on the stack projects to its scalar leaves only, no further descent. - let recursing = stack.iter().any(|n| n == type_name); - stack.push(type_name.to_string()); - - let mut out = Vec::new(); - for field in fields { - if !recursing { - if let Some(nested_name) = referenced_struct(&field.shape, table) { - if let Some(sub) = project_inner(nested_name, table, stack) { - out.push(ShapeField::Nested(field.name.to_string(), sub)); - continue; - } - } - } - out.push(ShapeField::Leaf(field.name.to_string())); - } - - stack.pop(); - Some(QueryProjection { - type_name: type_name.to_string(), - fields: out, - }) -} - -/// Derive the projection for a function's output type, by function name. -pub fn project_function_output(fn_name: &str) -> Option { - let fn_spec = crate::registry::lookup_function(fn_name)?; - project(fn_spec.output_type()) -} diff --git a/cores/mizan-rust/src/ssr.rs b/cores/mizan-rust/src/ssr.rs deleted file mode 100644 index 7c1d53a..0000000 --- a/cores/mizan-rust/src/ssr.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! SSR bridge — drive a persistent Bun subprocess for React `renderToString`. -//! -//! Same wire protocol as the Python `SSRBridge` -//! (`backends/mizan-django/src/mizan/ssr/bridge.py`): newline-delimited -//! JSON-RPC over the worker's stdin/stdout. -//! -//! → {"id": 1, "method": "render", "params": {"file": "/abs/X.tsx", "props": {...}}} -//! ← {"id": 1, "html": "
...
"} -//! -//! The worker emits `{"id": 0, "ready": true}` once on startup; `render` -//! blocks until that arrives. A background reader thread demultiplexes -//! responses by `id` and parks each caller on a per-request condvar. The -//! subprocess stays alive across requests and is respawned on the next render -//! if it has died. `command` is injected so a test can drive the exact same -//! framing/correlation path against a stub worker without Bun installed. - -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; - -/// How the bridge launches its worker. The default is `bun run `; a -/// test injects a stub program that speaks the same JSON-RPC framing. -#[derive(Clone)] -pub struct WorkerCommand { - pub program: String, - pub args: Vec, -} - -impl WorkerCommand { - /// The production launcher: `bun run `. - pub fn bun(worker_path: impl Into) -> Self { - Self { - program: "bun".to_string(), - args: vec!["run".to_string(), worker_path.into()], - } - } -} - -#[derive(Debug)] -pub enum SsrError { - Spawn(String), - Timeout(String), - Render(String), - Pipe(String), -} - -impl std::fmt::Display for SsrError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SsrError::Spawn(m) => write!(f, "SSR worker spawn failed: {m}"), - SsrError::Timeout(m) => write!(f, "SSR render timed out: {m}"), - SsrError::Render(m) => write!(f, "SSR render failed: {m}"), - SsrError::Pipe(m) => write!(f, "SSR worker pipe broken: {m}"), - } - } -} - -impl std::error::Error for SsrError {} - -/// Shared slot a parked caller waits on. The reader thread fills `result` and -/// flips `done`, then notifies. -#[derive(Default)] -struct Slot { - done: Mutex>, - cv: Condvar, -} - -struct Inner { - child: Option, - stdin: Option, - pending: Arc>>>, - ready: Arc<(Mutex, Condvar)>, - counter: u64, -} - -/// A persistent Bun SSR subprocess, thread-safe across concurrent `render`s. -pub struct SsrBridge { - command: WorkerCommand, - timeout: Duration, - inner: Mutex, -} - -impl SsrBridge { - pub fn new(command: WorkerCommand, timeout: Duration) -> Self { - Self { - command, - timeout, - inner: Mutex::new(Inner { - child: None, - stdin: None, - pending: Arc::new(Mutex::new(HashMap::new())), - ready: Arc::new((Mutex::new(false), Condvar::new())), - counter: 0, - }), - } - } - - /// Production constructor: `bun run ` with a 5s render timeout. - pub fn bun(worker_path: impl Into) -> Self { - Self::new(WorkerCommand::bun(worker_path), Duration::from_secs(5)) - } - - fn ensure_running(&self, inner: &mut Inner) -> Result<(), SsrError> { - if let Some(child) = inner.child.as_mut() { - if matches!(child.try_wait(), Ok(None)) { - return Ok(()); // still alive - } - } - - *inner.ready.0.lock().unwrap() = false; - inner.pending.lock().unwrap().clear(); - - let mut child = Command::new(&self.command.program) - .args(&self.command.args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| SsrError::Spawn(e.to_string()))?; - - let stdout = child - .stdout - .take() - .ok_or_else(|| SsrError::Spawn("no stdout".into()))?; - inner.stdin = Some( - child - .stdin - .take() - .ok_or_else(|| SsrError::Spawn("no stdin".into()))?, - ); - inner.child = Some(child); - - let pending = inner.pending.clone(); - let ready = inner.ready.clone(); - std::thread::Builder::new() - .name("mizan-ssr-reader".to_string()) - .spawn(move || Self::read_loop(stdout, pending, ready)) - .map_err(|e| SsrError::Spawn(e.to_string()))?; - - // Block until the worker signals readiness. - let (lock, cv) = &*inner.ready; - let mut is_ready = lock.lock().unwrap(); - while !*is_ready { - let (g, timed_out) = cv.wait_timeout(is_ready, self.timeout).unwrap(); - is_ready = g; - if timed_out.timed_out() && !*is_ready { - return Err(SsrError::Timeout("worker failed to start".into())); - } - } - Ok(()) - } - - fn read_loop( - stdout: std::process::ChildStdout, - pending: Arc>>>, - ready: Arc<(Mutex, Condvar)>, - ) { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let line = match line { - Ok(l) => l, - Err(_) => break, - }; - let line = line.trim(); - if line.is_empty() { - continue; - } - let msg: Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => continue, // malformed line; skip, matching Python - }; - let id = msg.get("id").and_then(|v| v.as_u64()); - - // Ready signal: {"id": 0, "ready": true}. - if id == Some(0) && msg.get("ready").and_then(|r| r.as_bool()) == Some(true) { - let (lock, cv) = &*ready; - *lock.lock().unwrap() = true; - cv.notify_all(); - continue; - } - - if let Some(id) = id { - let slot = pending.lock().unwrap().remove(&id); - if let Some(slot) = slot { - *slot.done.lock().unwrap() = Some(msg); - slot.cv.notify_all(); - } - } - } - } - - /// Render `file` (an absolute `.tsx`/`.jsx` path) with `props`, returning - /// the HTML string. Spawns the worker on first use; respawns if it died. - pub fn render(&self, file: &str, props: Value) -> Result { - let (id, stdin_taken, slot) = { - let mut inner = self.inner.lock().unwrap(); - self.ensure_running(&mut inner)?; - inner.counter += 1; - let id = inner.counter; - let slot = Arc::new(Slot::default()); - inner.pending.lock().unwrap().insert(id, slot.clone()); - - let request = json!({ - "id": id, - "method": "render", - "params": {"file": file, "props": props}, - }); - let mut line = serde_json::to_string(&request).unwrap(); - line.push('\n'); - - let write_res = inner - .stdin - .as_mut() - .ok_or_else(|| SsrError::Pipe("no stdin".into())) - .and_then(|w| { - w.write_all(line.as_bytes()) - .and_then(|_| w.flush()) - .map_err(|e| SsrError::Pipe(e.to_string())) - }); - (id, write_res, slot) - }; - - if let Err(e) = stdin_taken { - self.inner.lock().unwrap().pending.lock().unwrap().remove(&id); - return Err(e); - } - - // Park on the slot until the reader fills it or we time out. - let mut done = slot.done.lock().unwrap(); - while done.is_none() { - let (g, timed_out) = slot.cv.wait_timeout(done, self.timeout).unwrap(); - done = g; - if timed_out.timed_out() && done.is_none() { - self.inner.lock().unwrap().pending.lock().unwrap().remove(&id); - return Err(SsrError::Timeout(format!("render of {file:?}"))); - } - } - let msg = done.take().unwrap(); - drop(done); - - if let Some(err) = msg.get("error").and_then(|e| e.as_str()) { - return Err(SsrError::Render(err.to_string())); - } - match msg.get("html").and_then(|h| h.as_str()) { - Some(html) => Ok(html.to_string()), - None => Err(SsrError::Render("response missing `html`".into())), - } - } - - /// Stop the subprocess. Idempotent; called from `Drop`. - pub fn shutdown(&self) { - let mut inner = self.inner.lock().unwrap(); - inner.stdin = None; // close stdin → worker sees EOF - if let Some(mut child) = inner.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -impl Drop for SsrBridge { - fn drop(&mut self) { - self.shutdown(); - } -} diff --git a/cores/mizan-rust/src/traits.rs b/cores/mizan-rust/src/traits.rs index c6e2e36..38f8b65 100644 --- a/cores/mizan-rust/src/traits.rs +++ b/cores/mizan-rust/src/traits.rs @@ -53,12 +53,6 @@ pub trait FunctionSpec: Send + Sync { fn private(&self) -> bool { false } - /// The `@client(auth=...)` requirement, as the IR string form: `None` - /// (no guard), `"required"`, `"staff"`, or `"superuser"`. The dispatch - /// core resolves this into an `AuthRequirement` and rejects accordingly. - fn auth(&self) -> Option<&'static str> { - None - } fn is_form(&self) -> bool { false } diff --git a/cores/mizan-rust/src/upload.rs b/cores/mizan-rust/src/upload.rs deleted file mode 100644 index d49e1a2..0000000 --- a/cores/mizan-rust/src/upload.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Upload — first-class binary input for `#[mizan::client]` functions. -//! -//! Rust analog of `cores/mizan-python/src/mizan_core/upload.py`. An adapter -//! parses a multipart file part and binds it into the function's typed input -//! as the JSON shape `Upload` deserializes: -//! -//! ```json -//! {"filename": "a.png", "content_type": "image/png", "data_b64": "...", "size": 12} -//! ``` -//! -//! Declaring an `Upload`-typed parameter makes a function multipart-aware end -//! to end (the generated client switches the call to `multipart/form-data`; -//! each adapter binds the part). `Upload` is `Deserialize`, so it drops into a -//! `#[mizan(...)]` input struct like any other field and the dispatch -//! wrapper's `serde_json::from_value` validates it. - -use base64::engine::general_purpose::STANDARD; -use base64::Engine; -use serde::de::{self, Deserializer}; -use serde::Deserialize; - -/// A bound, decoded upload handed to a `#[mizan::client]` function. The bytes -/// are eagerly decoded from the adapter's base64 transport form. -#[derive(Debug, Clone)] -pub struct Upload { - pub filename: Option, - pub content_type: Option, - data: Vec, -} - -impl Upload { - pub fn size(&self) -> usize { - self.data.len() - } - - pub fn bytes(&self) -> &[u8] { - &self.data - } - - /// Persist the upload to `path`. - pub fn save(&self, path: impl AsRef) -> std::io::Result<()> { - std::fs::write(path, &self.data) - } -} - -/// The wire form an adapter encodes a file part into. Kept separate from -/// `Upload` so the public handle exposes decoded bytes, not base64. -#[derive(Deserialize)] -struct UploadWire { - #[serde(default)] - filename: Option, - #[serde(default)] - content_type: Option, - data_b64: String, -} - -impl<'de> Deserialize<'de> for Upload { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let wire = UploadWire::deserialize(deserializer)?; - let data = STANDARD - .decode(wire.data_b64.as_bytes()) - .map_err(|e| de::Error::custom(format!("invalid base64 upload data: {e}")))?; - Ok(Upload { - filename: wire.filename, - content_type: wire.content_type, - data, - }) - } -} diff --git a/cores/mizan-rust/tests/cache_keys_pin.rs b/cores/mizan-rust/tests/cache_keys_pin.rs deleted file mode 100644 index c272aa1..0000000 --- a/cores/mizan-rust/tests/cache_keys_pin.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Cross-language pin: Rust `derive_cache_key` must be byte-identical to the -//! Python reference (`cores/mizan-python/.../cache/keys.py`) and to the -//! committed cross-language vectors that `tests/afi` and `mizan-ts` also pin. -//! -//! The Python reference is the oracle: a subprocess mints the key with fixed -//! inputs and the Rust output must match exactly. `never if backend == X` — -//! one spec, pinned both ways. - -use mizan_core::derive_cache_key; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::process::Command; - -/// The `tests/afi` dir, whose venv has `mizan_core` + PyJWT installed. -fn afi_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/afi") - .canonicalize() - .expect("tests/afi exists") -} - -/// Run the Python reference via `uv run python -c ` in tests/afi and -/// return its single stdout line, trimmed. -fn py(code: &str) -> String { - let out = Command::new("uv") - .args(["run", "python", "-c", code]) - .current_dir(afi_dir()) - .output() - .expect("invoke uv run python"); - assert!( - out.status.success(), - "python reference failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr), - ); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -fn tree(pairs: &[(&str, Value)]) -> BTreeMap { - pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() -} - -#[test] -fn committed_vectors_match() { - // The exact pins committed in cores/mizan-python/tests/test_keys.py and - // backends/mizan-ts/tests — the canonical cross-language anchor. - let secret = "test-pin-secret-that-is-32bytes!"; - - let public = derive_cache_key(secret, "user", &tree(&[("user_id", json!("5"))]), None, 0); - assert_eq!( - public, - "ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6" - ); - - let scoped = derive_cache_key( - secret, - "user", - &tree(&[("user_id", json!("5"))]), - Some("5"), - 0, - ); - assert_eq!( - scoped, - "ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2" - ); -} - -#[test] -fn matches_python_reference_across_inputs() { - // A spread of shapes: multi-param (order-independence), numeric vs string, - // bool/null normalization, user-scoped, nonzero rev. - let cases: Vec<(&str, BTreeMap, Option<&str>, i64)> = vec![ - ("user", tree(&[("user_id", json!("5"))]), None, 0), - ("user", tree(&[("user_id", json!("5"))]), Some("5"), 0), - ("user", tree(&[("user_id", json!("5"))]), Some("5"), 3), - ( - "search", - tree(&[("q", json!("hello world")), ("page", json!(2))]), - None, - 0, - ), - ( - "flags", - tree(&[("on", json!(true)), ("off", json!(false)), ("nil", json!(null))]), - Some("42"), - 1, - ), - ("empty", tree(&[]), None, 0), - ( - "unicode", - tree(&[("name", json!("café—ñ"))]), - None, - 0, - ), - ]; - - for (ctx, params, uid, rev) in cases { - let rust = derive_cache_key("pin-secret-xyz", ctx, ¶ms, uid, rev); - - // Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev). - let params_json = serde_json::to_string( - ¶ms.iter().map(|(k, v)| (k.clone(), v.clone())).collect::>(), - ) - .unwrap(); - let uid_arg = match uid { - Some(u) => format!("'{u}'"), - None => "None".to_string(), - }; - let code = format!( - "import json; from mizan_core.cache.keys import derive_cache_key; \ - print(derive_cache_key('pin-secret-xyz', {ctx:?}, json.loads(r'''{params_json}'''), {uid_arg}, {rev}))", - ); - let expected = py(&code); - assert_eq!( - rust, expected, - "cache-key mismatch for ctx={ctx} params={params_json} uid={uid:?} rev={rev}", - ); - } -} diff --git a/cores/mizan-rust/tests/invalidate_header_pin.rs b/cores/mizan-rust/tests/invalidate_header_pin.rs deleted file mode 100644 index 82ac8cf..0000000 --- a/cores/mizan-rust/tests/invalidate_header_pin.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Cross-language pin: Rust `format_invalidate_header` must be byte-identical -//! to `cores/mizan-python/.../invalidation.py::format_invalidate_header`. -//! -//! The `X-Mizan-Invalidate` header is co-equal with the JSON body channel in -//! the spec; Edge parses it to purge. The Python reference is the oracle. - -use mizan_core::{format_invalidate_header, InvalidationTarget}; -use serde_json::json; -use std::path::PathBuf; -use std::process::Command; - -fn afi_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/afi") - .canonicalize() - .expect("tests/afi exists") -} - -fn py_header(json_list: &str) -> String { - let code = format!( - "import json; from mizan_core.invalidation import format_invalidate_header; \ - print(format_invalidate_header(json.loads(r'''{json_list}''')))", - ); - let out = Command::new("uv") - .args(["run", "python", "-c", &code]) - .current_dir(afi_dir()) - .output() - .expect("invoke uv run python"); - assert!( - out.status.success(), - "python reference failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - // Trim the trailing newline only — the header value itself may be empty. - let s = String::from_utf8(out.stdout).unwrap(); - s.strip_suffix('\n').unwrap_or(&s).to_string() -} - -fn scoped(ctx: &str, params: &[(&str, serde_json::Value)]) -> InvalidationTarget { - InvalidationTarget::ScopedContext { - context: ctx.to_string(), - params: params.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), - } -} - -#[test] -fn matches_python_reference() { - let cases: Vec<(Vec, &str)> = vec![ - (vec![InvalidationTarget::Context("user".into())], r#"["user"]"#), - ( - vec![ - InvalidationTarget::Context("user".into()), - InvalidationTarget::Context("notifications".into()), - ], - r#"["user", "notifications"]"#, - ), - ( - vec![scoped("user", &[("user_id", json!(5))])], - r#"[{"context": "user", "params": {"user_id": 5}}]"#, - ), - ( - vec![scoped("search", &[("q", json!("hello world"))])], - r#"[{"context": "search", "params": {"q": "hello world"}}]"#, - ), - ( - // Multiple params → sorted by key, semicolon-joined. - vec![scoped("u", &[("b", json!("2")), ("a", json!("1"))])], - r#"[{"context": "u", "params": {"b": "2", "a": "1"}}]"#, - ), - ( - // Special chars that must percent-encode: &, =, /, space, unicode. - vec![scoped("c", &[("k", json!("a&b=c/d e—ñ"))])], - r#"[{"context": "c", "params": {"k": "a&b=c/d e—ñ"}}]"#, - ), - ( - // Mixed bare + scoped. - vec![ - scoped("user", &[("user_id", json!(5))]), - InvalidationTarget::Context("notifications".into()), - ], - r#"[{"context": "user", "params": {"user_id": 5}}, "notifications"]"#, - ), - ]; - - for (targets, json_list) in cases { - let rust = format_invalidate_header(&targets); - let expected = py_header(json_list); - assert_eq!(rust, expected, "header mismatch for {json_list}"); - } -} diff --git a/cores/mizan-rust/tests/shapes_manifest.rs b/cores/mizan-rust/tests/shapes_manifest.rs deleted file mode 100644 index de1585f..0000000 --- a/cores/mizan-rust/tests/shapes_manifest.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Behavior tests for the Shapes projection + edge-manifest derivation, -//! driven off a small registered fixture (same graph the AFI fixture uses: -//! a nested struct, a user context with a shared `user_id` param, and an -//! `affects` mutation). - -use mizan_core as mizan; -use mizan_core::prelude::*; -use mizan_core::{generate_edge_manifest, shapes, RequestHandle}; -use serde::{Deserialize, Serialize}; - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Address { - pub city: String, - pub zip: String, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Person { - pub user_id: i64, - pub name: String, - pub address: Address, -} - -#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] -pub struct Ok { - pub ok: bool, -} - -#[mizan::context("people")] -pub struct PeopleCtx; - -#[mizan::client(context = PeopleCtx)] -pub async fn person(_req: &RequestHandle<'_>, user_id: i64) -> Person { - Person { - user_id, - name: "x".into(), - address: Address { - city: "c".into(), - zip: "z".into(), - }, - } -} - -#[mizan::client(affects = PeopleCtx)] -pub async fn rename_person(_req: &RequestHandle<'_>, user_id: i64, _name: String) -> Ok { - let _ = user_id; - Ok { ok: true } -} - -#[test] -fn shapes_projection_descends_nested_structs() { - let proj = shapes::project_function_output("person").expect("projects"); - assert_eq!(proj.type_name, "personOutput"); - // Scalar leaves at the top level. - let leaves = proj.leaf_names(); - assert!(leaves.contains(&"user_id")); - assert!(leaves.contains(&"name")); - // `address` is a nested struct → a sub-projection, not a leaf. - assert!(!leaves.contains(&"address")); - let nested = proj.nested(); - assert_eq!(nested.len(), 1); - let (name, sub) = nested[0]; - assert_eq!(name, "address"); - let sub_leaves = sub.leaf_names(); - assert!(sub_leaves.contains(&"city") && sub_leaves.contains(&"zip")); -} - -#[test] -fn edge_manifest_has_context_render_strategy_and_mutation() { - let m = generate_edge_manifest("/api/mizan"); - - // Context: user-scoped (has `user_id`) → render_strategy dynamic_cached. - let people = &m["contexts"]["people"]; - assert_eq!(people["user_scoped"], serde_json::json!(true)); - assert_eq!(people["render_strategy"], serde_json::json!("dynamic_cached")); - assert_eq!( - people["endpoints"], - serde_json::json!(["/api/mizan/ctx/people/"]) - ); - assert_eq!(people["params"], serde_json::json!(["user_id"])); - - // Mutation: rename_person affects people, auto-scopes user_id. - let mutation = &m["mutations"]["rename_person"]; - assert_eq!(mutation["affects"], serde_json::json!(["people"])); - assert_eq!( - mutation["auto_scoped_params"], - serde_json::json!(["user_id"]) - ); -} diff --git a/cores/mizan-rust/tests/ssr_bridge.rs b/cores/mizan-rust/tests/ssr_bridge.rs deleted file mode 100644 index 383854c..0000000 --- a/cores/mizan-rust/tests/ssr_bridge.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Behavior test for the SSR bridge's framing + request/response correlation. -//! -//! Bun isn't required (it isn't installed in CI): a stub worker speaking the -//! exact same newline-delimited JSON-RPC protocol stands in. The stub emits -//! the `{"id":0,"ready":true}` handshake, then for each `render` request -//! echoes back `{"id":N,"html":""}` — exercising -//! the ready-gate, the per-request id correlation, and the html extraction -//! that the real Bun worker drives. - -use mizan_core::{SsrBridge, WorkerCommand}; -use serde_json::json; -use std::io::Write; -use std::time::Duration; - -/// A tiny Python stub that speaks the SSR worker protocol. Written to a temp -/// file and launched via `python3 `. -const STUB: &str = r#" -import sys, json -# Handshake: announce readiness exactly as the Bun worker does. -sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\n") -sys.stdout.flush() -for line in sys.stdin: - line = line.strip() - if not line: - continue - msg = json.loads(line) - mid = msg.get("id") - if msg.get("method") == "render": - p = msg["params"] - # A sentinel file name forces the worker-error branch. - if p["file"] == "/boom.tsx": - sys.stdout.write(json.dumps({"id": mid, "error": "render exploded"}) + "\n") - else: - html = "" % (p["file"], json.dumps(p["props"], sort_keys=True)) - sys.stdout.write(json.dumps({"id": mid, "html": html}) + "\n") - else: - sys.stdout.write(json.dumps({"id": mid, "error": "unknown method"}) + "\n") - sys.stdout.flush() -"#; - -fn write_stub() -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("mizan_ssr_stub_{}.py", std::process::id())); - let mut f = std::fs::File::create(&path).unwrap(); - f.write_all(STUB.as_bytes()).unwrap(); - path -} - -#[test] -fn bridge_drives_worker_protocol() { - let stub = write_stub(); - let bridge = SsrBridge::new( - WorkerCommand { - program: "python3".to_string(), - args: vec![stub.to_string_lossy().to_string()], - }, - Duration::from_secs(5), - ); - - // First render — spawns the worker, waits for the ready handshake. - let html = bridge - .render("/abs/Hello.tsx", json!({"name": "World"})) - .expect("first render succeeds"); - assert_eq!( - html, - r#""# - ); - - // Second render reuses the same subprocess; id correlation must keep the - // responses matched to their requests. - let html2 = bridge - .render("/abs/Other.tsx", json!({"a": 1, "b": 2})) - .expect("second render succeeds"); - assert_eq!( - html2, - r#""# - ); - - bridge.shutdown(); - let _ = std::fs::remove_file(&stub); -} - -#[test] -fn bridge_propagates_worker_error() { - let stub = write_stub(); - let bridge = SsrBridge::new( - WorkerCommand { - program: "python3".to_string(), - args: vec![stub.to_string_lossy().to_string()], - }, - Duration::from_secs(5), - ); - // The sentinel file makes the stub return an `error` frame; the bridge - // must surface it as `SsrError::Render`, not a successful empty render. - let err = bridge - .render("/boom.tsx", json!({})) - .expect_err("worker error propagates"); - assert!(matches!(err, mizan_core::SsrError::Render(_))); - assert!(err.to_string().contains("render exploded")); - - // A subsequent good render on the same worker still succeeds. - assert!(bridge.render("/ok.tsx", json!({})).is_ok()); - bridge.shutdown(); - let _ = std::fs::remove_file(&stub); -} diff --git a/cores/mizan-rust/tests/token_pin.rs b/cores/mizan-rust/tests/token_pin.rs deleted file mode 100644 index 2fc764a..0000000 --- a/cores/mizan-rust/tests/token_pin.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Cross-language pin: Rust HS256 JWT + MWT must be byte-identical to the -//! Python core (`auth/jwt.py`, `mwt.py`, both PyJWT-backed). -//! -//! Byte-identity is the whole point — Edge and the origin cache key on these -//! tokens, so a one-byte divergence is a cache-key spoof surface. The Python -//! reference is the oracle: it mints with fixed claims + a fixed `iat`/`exp` -//! (we pin `now` on both sides) and the Rust token must match exactly. We also -//! prove round-trip: Rust decodes a Python-minted token and vice-versa. - -use mizan_core::{ - create_access_token, create_mwt, create_refresh_token, decode_jwt, decode_mwt, - compute_permission_key, JwtConfig, -}; -use std::path::PathBuf; -use std::process::Command; - -fn afi_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/afi") - .canonicalize() - .expect("tests/afi exists") -} - -fn py(code: &str) -> String { - let out = Command::new("uv") - .args(["run", "python", "-c", code]) - .current_dir(afi_dir()) - .output() - .expect("invoke uv run python"); - assert!( - out.status.success(), - "python reference failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr), - ); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -const NOW: i64 = 1_700_000_000; - -#[test] -fn jwt_access_token_matches_python() { - let cfg = JwtConfig::new("jwt-pin-secret"); - let rust = create_access_token(&cfg, "42", "sess-abc", true, false, NOW); - - // Python: freeze time to NOW, mint an access token with the same claims. - let code = format!( - "import time; from unittest import mock; \ - from mizan_core.auth.jwt import JWTConfig, create_access_token; \ - cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \ - orig = time.time; \ - time.time = lambda: {NOW}; \ - print(create_access_token('42', 'sess-abc', cfg, is_staff=True, is_superuser=False)); \ - time.time = orig", - ); - let expected = py(&code); - assert_eq!(rust, expected, "JWT access-token byte mismatch"); -} - -#[test] -fn jwt_refresh_token_matches_python() { - let cfg = JwtConfig::new("jwt-pin-secret"); - let rust = create_refresh_token(&cfg, "7", "sid-9", false, true, NOW); - let code = format!( - "import time; from mizan_core.auth.jwt import JWTConfig, create_refresh_token; \ - cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \ - time.time = lambda: {NOW}; \ - print(create_refresh_token('7', 'sid-9', cfg, is_staff=False, is_superuser=True))", - ); - assert_eq!(rust, py(&code), "JWT refresh-token byte mismatch"); -} - -#[test] -fn jwt_roundtrip_decode_python_minted() { - // A Python-minted access token must decode in Rust with matching claims. - let code = format!( - "import time; from mizan_core.auth.jwt import JWTConfig, create_access_token; \ - cfg = JWTConfig(private_key='rt-secret', public_key='rt-secret'); \ - time.time = lambda: {NOW}; \ - print(create_access_token('99', 'sess-x', cfg, is_staff=False, is_superuser=True))", - ); - let token = py(&code); - let cfg = JwtConfig::new("rt-secret"); - let payload = decode_jwt(&token, &cfg, Some("access"), NOW + 10).expect("decodes"); - assert_eq!(payload.sub, "99"); - assert_eq!(payload.sid, "sess-x"); - assert!(payload.superuser); - assert!(!payload.staff); - // Wrong secret → None; expired → None. - assert!(decode_jwt(&token, &JwtConfig::new("nope"), None, NOW + 10).is_none()); - assert!(decode_jwt(&token, &cfg, Some("access"), NOW + 10_000).is_none()); - // Type mismatch → None. - assert!(decode_jwt(&token, &cfg, Some("refresh"), NOW + 10).is_none()); -} - -#[test] -fn permission_key_matches_python() { - let perms = vec!["app.add_thing".to_string(), "app.view_thing".to_string()]; - let rust = compute_permission_key(true, false, &perms); - let code = - "from mizan_core.mwt import compute_permission_key; \ - from unittest.mock import MagicMock; \ - u = MagicMock(); u.is_staff=True; u.is_superuser=False; \ - u.get_all_permissions = MagicMock(return_value={'app.view_thing','app.add_thing'}); \ - print(compute_permission_key(u))"; - assert_eq!(rust, py(code), "pkey byte mismatch"); -} - -#[test] -fn mwt_matches_python() { - // Build the same pkey on both sides, then mint with frozen time + fixed - // kid/audience and compare bytes. - let perms = vec!["app.view_thing".to_string()]; - let pkey = compute_permission_key(false, false, &perms); - let rust = create_mwt("mwt-pin-secret", "5", false, false, &pkey, 300, "mizan", "v1", NOW); - - let code = format!( - "import time; from unittest.mock import MagicMock; \ - from mizan_core.mwt import create_mwt; \ - u = MagicMock(); u.pk=5; u.is_staff=False; u.is_superuser=False; \ - u.get_all_permissions = MagicMock(return_value={{'app.view_thing'}}); \ - time.time = lambda: {NOW}; \ - print(create_mwt(u, 'mwt-pin-secret', ttl=300, audience='mizan', kid='v1'))", - ); - assert_eq!(rust, py(&code), "MWT byte mismatch"); -} - -#[test] -fn mwt_roundtrip_and_rejections() { - let pkey = compute_permission_key(true, true, &[]); - let token = create_mwt("rt-mwt", "13", true, true, &pkey, 300, "mizan", "v1", NOW); - let p = decode_mwt(&token, "rt-mwt", "mizan", NOW + 5).expect("decodes"); - assert_eq!(p.sub, "13"); - assert!(p.staff && p.superuser); - assert_eq!(p.kid, "v1"); - assert_eq!(p.pkey.len(), 64); - // Wrong secret, wrong audience, expired → None. - assert!(decode_mwt(&token, "wrong", "mizan", NOW + 5).is_none()); - assert!(decode_mwt(&token, "rt-mwt", "other", NOW + 5).is_none()); - assert!(decode_mwt(&token, "rt-mwt", "mizan", NOW + 10_000).is_none()); - - // And a Python-minted MWT decodes in Rust. - let code = format!( - "import time; from unittest.mock import MagicMock; from mizan_core.mwt import create_mwt; \ - u = MagicMock(); u.pk=21; u.is_staff=True; u.is_superuser=False; \ - u.get_all_permissions = MagicMock(return_value=set()); \ - time.time = lambda: {NOW}; print(create_mwt(u, 'rt-mwt', ttl=300, audience='mizan', kid='v1'))", - ); - let py_token = py(&code); - let pp = decode_mwt(&py_token, "rt-mwt", "mizan", NOW + 5).expect("py mwt decodes in rust"); - assert_eq!(pp.sub, "21"); - assert!(pp.staff && !pp.superuser); -} diff --git a/frontends/mizan-base/src/index.ts b/frontends/mizan-base/src/index.ts index 1a6b8f3..62eca86 100644 --- a/frontends/mizan-base/src/index.ts +++ b/frontends/mizan-base/src/index.ts @@ -386,18 +386,6 @@ async function resolveHeaders(): Promise> { } } -/** 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): boolean { - return Object.values(args).some( - (v) => isFile(v) || (Array.isArray(v) && v.some(isFile)), - ) -} - /** * Default Mizan transport — POST `${baseUrl}/call/` and GET * `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`, @@ -409,38 +397,13 @@ export function httpTransport(): MizanTransport { return { async call(functionName, args) { 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 = {} - 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' - body = JSON.stringify({ fn: functionName, args }) - } + headers['Content-Type'] = 'application/json' const res = await fetch(`${config.baseUrl}/call/`, { method: 'POST', headers, credentials: 'same-origin', - body, + body: JSON.stringify({ fn: functionName, args }), }) if (!res.ok) throw new MizanError(res.status, await res.text()) return res.json() diff --git a/protocol/mizan-codegen/src/emit/channels.rs b/protocol/mizan-codegen/src/emit/channels.rs index 51205c1..8b84a3d 100644 --- a/protocol/mizan-codegen/src/emit/channels.rs +++ b/protocol/mizan-codegen/src/emit/channels.rs @@ -165,7 +165,6 @@ fn ts_type_expression(shape: &TypeShape) -> String { .map(ts_type_expression) .collect::>() .join(" | "), - TypeShape::Upload(_) => "File".to_string(), } } diff --git a/protocol/mizan-codegen/src/emit/python.rs b/protocol/mizan-codegen/src/emit/python.rs index 89ff77b..310fa28 100644 --- a/protocol/mizan-codegen/src/emit/python.rs +++ b/protocol/mizan-codegen/src/emit/python.rs @@ -118,9 +118,6 @@ fn py_type_expression(shape: &TypeShape) -> String { .map(py_type_expression) .collect::>() .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(), } } diff --git a/protocol/mizan-codegen/src/emit/rust.rs b/protocol/mizan-codegen/src/emit/rust.rs index 7a5b89d..ba1a32e 100644 --- a/protocol/mizan-codegen/src/emit/rust.rs +++ b/protocol/mizan-codegen/src/emit/rust.rs @@ -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. "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".to_string(), } } diff --git a/protocol/mizan-codegen/src/emit/stage1.rs b/protocol/mizan-codegen/src/emit/stage1.rs index 0378e2c..c23bfbe 100644 --- a/protocol/mizan-codegen/src/emit/stage1.rs +++ b/protocol/mizan-codegen/src/emit/stage1.rs @@ -296,6 +296,5 @@ fn ts_type_expression(shape: &TypeShape) -> String { .map(ts_type_expression) .collect::>() .join(" | "), - TypeShape::Upload(_) => "File".to_string(), } } diff --git a/protocol/mizan-codegen/src/ir.rs b/protocol/mizan-codegen/src/ir.rs index 26055d8..544594e 100644 --- a/protocol/mizan-codegen/src/ir.rs +++ b/protocol/mizan-codegen/src/ir.rs @@ -46,15 +46,6 @@ pub enum TypeShape { Enum(Vec), /// Multi-arm union with two or more non-null branches. Union(Vec), - /// Binary file input. Carries the declarative `File(...)` constraints. - Upload(UploadConstraints), -} - - -#[derive(Debug, Clone, Default)] -pub struct UploadConstraints { - pub max_size: Option, - pub content_types: Vec, } @@ -282,7 +273,6 @@ fn parse_type_shape(node: &KdlNode) -> Result { "list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))), "optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))), "enum" => Ok(TypeShape::Enum(parse_string_args(node))), - "upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))), "union" => { let children = node.children() .ok_or_else(|| anyhow!("union: missing children"))?; @@ -295,20 +285,6 @@ fn parse_type_shape(node: &KdlNode) -> Result { } -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 { let name = first_string_arg(node) .context("`function` requires a name as its first argument")?; diff --git a/protocol/mizan-codegen/tests/upload_codegen.rs b/protocol/mizan-codegen/tests/upload_codegen.rs deleted file mode 100644 index 577ad8a..0000000 --- a/protocol/mizan-codegen/tests/upload_codegen.rs +++ /dev/null @@ -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}"); -} diff --git a/tests/afi/fixture.py b/tests/afi/fixture.py index f80b65e..2447083 100644 --- a/tests/afi/fixture.py +++ b/tests/afi/fixture.py @@ -7,11 +7,7 @@ exercise the protocol axes both backends must agree on: - two context functions sharing a param (proves bundling + param elevation) - a mutation declaring `affects` on the context -This fixture is deliberately minimal: it exercises the axes the *IR-shape* -parity test (`test_codegen_parity.py`) needs to compare. It intentionally omits -channels / forms / shapes — NOT because those are outside the AFI (they are -AFI-common; see `manifest.py`), but because their per-adapter wiring is gated by -the *capability* parity suite (`test_capability_parity.py`), not by IR-shape. +No channels, no forms, no shapes — those aren't AFI-common. `register_fixture()` registers the functions with mizan_core.registry. Backend test apps import this module and call register_fixture() during diff --git a/tests/afi/manifest.py b/tests/afi/manifest.py deleted file mode 100644 index 54d270d..0000000 --- a/tests/afi/manifest.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -The AFI surface, as data — the single source of truth for what every Mizan -adapter owes. - -This module exists because "what is AFI-common" used to live as prose: a -README table, a fixture docstring, an adapter `__init__` comment. Prose drifts. -An adapter that didn't wire a capability got its gap relabelled "Django-only" -or "out of scope — use native equivalents," and nothing went red. Here the -surface is a list of `Capability` objects and the implementors are a list of -`Adapter` objects; the parity table and the conformance suite are both -*generated* from these two lists. A capability cannot be de-scoped by editing -a word — only by deleting it from `CAPABILITIES`, which is a reviewable diff, -not a buried cell. - -Applicability ("—" in the table) is DERIVED, never asserted: a capability that -`requires={"transport": "http"}` is simply not applicable to an IPC adapter. -The header-invalidation channel does not exist over Tauri IPC because IPC has -no headers — that is a fact about the transport, computed by `applies()`, not a -parity decision an agent gets to make. - -The line between AFI-common and genuinely-backend-bound: - - AFI-common Every adapter owes a binding. The protocol core, plus every - `register_extension` point (WebSocket, SSR, JWT, MWT, Shapes, - Forms). A missing one is a GAP (❌), never a category. - - Backend-bound Not in this manifest at all. `allauth` is a Django-ecosystem - package — legitimately Django-only. The *bindings* of common - capabilities are backend-specific (django-readers is Django's - Shapes binding; Django Forms is Django's Forms binding) — but - the *capability* is common, so it lives here and each adapter - owes its own binding, not an "N/A." -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum - - -class Tier(str, Enum): - """How the README groups capabilities — presentation only, not semantics.""" - - PROTOCOL_CORE = "Protocol core" - EDGE_CACHE = "Edge, cache & enforcement" - EXTENSION = "Extension points" - - -@dataclass(frozen=True) -class Capability: - """One thing every applicable adapter owes a binding for. - - `requires` declares preconditions an adapter must meet for this capability - to APPLY. The only key today is `transport` ("http" | "ipc"); a capability - that names one is inapplicable to adapters on the other, and `applies()` - renders that as a derived "—". An empty `requires` means transport-agnostic: - every adapter owes it, full stop. - """ - - id: str - title: str - tier: Tier - requires: dict[str, str] = field(default_factory=dict) - note: str = "" - - -@dataclass(frozen=True) -class Adapter: - """A backend adapter and the declared properties parity is computed against. - - `transport` is the load-bearing property: "http" adapters have a header - channel, an edge, a session endpoint; "ipc" adapters (Tauri) do not, and - those capabilities derive to "—" rather than counting as gaps. - """ - - id: str - title: str - language: str # "python" | "rust" | "typescript" - transport: str # "http" | "ipc" - - -# ─── The AFI-common surface ─────────────────────────────────────────────────── - -CAPABILITIES: list[Capability] = [ - # Protocol core — the wire contract every adapter implements. - Capability("rpc_call", "RPC call dispatch (`{result, invalidate}`)", Tier.PROTOCOL_CORE), - Capability("context_bundle", "Named-context bundle fetch", Tier.PROTOCOL_CORE), - Capability("invalidate_body", "Invalidation — JSON body", Tier.PROTOCOL_CORE), - Capability( - "invalidate_header", "Invalidation — `X-Mizan-Invalidate` header", - Tier.PROTOCOL_CORE, requires={"transport": "http"}, - note="The header channel is co-equal with the body channel in the spec. " - "IPC transports carry invalidation in the response envelope instead.", - ), - Capability("invalidate_autoscope", "Invalidation auto-scoping (three-tier)", Tier.PROTOCOL_CORE), - Capability("registration", "Function discovery / registration", Tier.PROTOCOL_CORE), - Capability("ir_export", "Codegen IR export (KDL)", Tier.PROTOCOL_CORE), - Capability("upload", "File uploads (`Upload` type)", Tier.PROTOCOL_CORE), - - # Edge, cache & enforcement. - Capability("auth_enforcement", "Auth-guard enforcement (`auth=…` rejects)", Tier.EDGE_CACHE), - Capability("origin_cache", "Origin-side HMAC cache", Tier.EDGE_CACHE), - Capability( - "edge_manifest", "Edge manifest export", Tier.EDGE_CACHE, - requires={"transport": "http"}, - note="The manifest configures an HTTP/CDN edge; a desktop IPC shell has no edge.", - ), - Capability( - "psr", "PSR (`render_strategy` in manifest)", Tier.EDGE_CACHE, - requires={"transport": "http"}, - ), - Capability( - "session_init", "Session / CSRF init endpoint", Tier.EDGE_CACHE, - requires={"transport": "http"}, - ), - - # Extension points — the `register_extension` surface. AFI-common, every one. - Capability("websocket", "WebSocket transport (`websocket=` declared)", Tier.EXTENSION), - Capability("ssr_bridge", "SSR bridge (subprocess renderer)", Tier.EXTENSION), - Capability("jwt", "JWT auth (access / refresh)", Tier.EXTENSION), - Capability( - "mwt", "MWT (edge identity token)", Tier.EXTENSION, - requires={"transport": "http"}, - note="MWT exists to key an edge cache; without an edge there is nothing to key.", - ), - Capability( - "shapes", "Typed query projection (Shapes)", Tier.EXTENSION, - note="The capability is AFI-common; the binding is per-ORM " - "(django-readers on Django, the project's ORM elsewhere).", - ), - Capability( - "forms", "Forms (schema / validate / submit)", Tier.EXTENSION, - note="The capability is AFI-common; the binding is per-framework " - "(Django Forms on Django, Pydantic-or-equivalent elsewhere).", - ), -] - - -# ─── The implementors ───────────────────────────────────────────────────────── - -ADAPTERS: list[Adapter] = [ - Adapter("django", "Django", "python", "http"), - Adapter("fastapi", "FastAPI", "python", "http"), - Adapter("rust_axum", "Rust / Axum", "rust", "http"), - Adapter("tauri", "Tauri", "rust", "ipc"), - Adapter("typescript", "TypeScript", "typescript", "http"), -] - - -CAPABILITIES_BY_ID: dict[str, Capability] = {c.id: c for c in CAPABILITIES} -ADAPTERS_BY_ID: dict[str, Adapter] = {a.id: a for a in ADAPTERS} - -# Every capability owes exactly one probe in probes.py. The meta-conformance -# test pins this so a capability can't be added without its gate. -PROBES_REQUIRED: int = len(CAPABILITIES) - - -def applies(capability: Capability, adapter: Adapter) -> bool: - """Whether `capability` is applicable to `adapter`, from declared properties. - - This is the ONLY source of "—" in the parity table. A `False` here is a - transport fact (IPC has no HTTP header channel), not a parity verdict. - """ - required_transport = capability.requires.get("transport") - if required_transport is not None and adapter.transport != required_transport: - return False - return True diff --git a/tests/afi/parity_table.py b/tests/afi/parity_table.py deleted file mode 100644 index b98b3a9..0000000 --- a/tests/afi/parity_table.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Generate the README parity table from the conformance probes. - -The table in the README is *output*, not *input*. It is computed by running -every probe over every adapter and rendering the result. An agent can no longer -type "Django-only" into a cell, because no one types cells — `make parity-table` -overwrites the block between the markers, and `--check` fails CI if the -committed block has drifted from what the probes actually report (the same -forcing function the codegen byte-parity tests already use). - -Glyphs: - ✅ wired the probe found the artifact - ◑ partial declared or stubbed, not complete — counts as RED in the suite - ❌ gap AFI-common, owed, not wired - — n/a the capability does not exist over this adapter's transport - (derived from `manifest.applies`, never asserted) - -Usage: - python parity_table.py --write # regenerate the README block - python parity_table.py --check # exit 1 if README block is stale - python parity_table.py # print the block to stdout -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from manifest import ADAPTERS, CAPABILITIES, Tier, applies -from probes import run_probe - -README = Path(__file__).resolve().parents[2] / "README.md" - -START = "" -END = "" - -_GLYPH = {"pass": "✅", "partial": "◑", "fail": "❌"} - - -def _cell(cap, adapter) -> str: - if not applies(cap, adapter): - return "—" - return _GLYPH[run_probe(cap.id, adapter).state] - - -def _tier_table(tier: Tier) -> str: - caps = [c for c in CAPABILITIES if c.tier is tier] - if not caps: - return "" - header = "| Capability | " + " | ".join(a.title for a in ADAPTERS) + " |" - sep = "|---|" + "|".join(":---:" for _ in ADAPTERS) + "|" - rows = [] - for cap in caps: - cells = " | ".join(_cell(cap, a) for a in ADAPTERS) - rows.append(f"| {cap.title} | {cells} |") - return f"### {tier.value}\n\n{header}\n{sep}\n" + "\n".join(rows) - - -def _notes() -> str: - noted = [c for c in CAPABILITIES if c.note] - if not noted: - return "" - lines = ["**Notes**", ""] - for c in noted: - lines.append(f"- **{c.title}** — {c.note}") - return "\n".join(lines) - - -def generate_block() -> str: - """The full generated parity block: legend, one table per tier, notes.""" - legend = ( - "Legend: ✅ wired · ◑ partial (declared/stubbed) · ❌ gap (AFI-common, owed) · " - "— not applicable to this adapter's transport\n\n" - "Every capability below is **AFI-common**: each adapter owes a binding, and a " - "❌ is a gap on the owed-work board (`tests/afi/`), never a category. " - "Backend-specific *bindings* of common capabilities (django-readers for Shapes, " - "Django Forms for Forms) and genuinely Django-ecosystem features (allauth) are " - "out of this matrix by design — see `tests/afi/manifest.py` for the line." - ) - parts = [legend] - for tier in (Tier.PROTOCOL_CORE, Tier.EDGE_CACHE, Tier.EXTENSION): - table = _tier_table(tier) - if table: - parts.append(table) - notes = _notes() - if notes: - parts.append(notes) - return "\n\n".join(parts) - - -def _wrap(block: str) -> str: - return f"{START}\n{block}\n{END}" - - -def _splice(readme_text: str, block: str) -> str: - if START not in readme_text or END not in readme_text: - raise SystemExit( - f"README markers not found. Add a block bounded by:\n {START}\n {END}\n" - f"to {README} where the parity table should render." - ) - pre = readme_text.split(START)[0] - post = readme_text.split(END)[1] - return pre + _wrap(block) + post - - -def main(argv: list[str]) -> int: - block = generate_block() - mode = argv[1] if len(argv) > 1 else "--print" - - if mode == "--print": - print(block) - return 0 - - text = README.read_text(encoding="utf-8") - spliced = _splice(text, block) - - if mode == "--write": - if spliced != text: - README.write_text(spliced, encoding="utf-8") - print(f"Wrote parity table to {README}") - else: - print("Parity table already current.") - return 0 - - if mode == "--check": - if spliced != text: - print( - "README parity table is STALE. The committed table does not match what " - "the conformance probes report.\nRun: make parity-table", - file=sys.stderr, - ) - return 1 - print("README parity table is current.") - return 0 - - print(f"Unknown mode: {mode}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/tests/afi/probes.py b/tests/afi/probes.py deleted file mode 100644 index 7c42f7b..0000000 --- a/tests/afi/probes.py +++ /dev/null @@ -1,388 +0,0 @@ -""" -Capability probes — the adversarial half of the conformance gate. - -A probe answers one question about one (capability, adapter) pair: *is this -capability actually wired into this adapter?* It answers by inspecting the -backend's own source for the concrete artifact the capability requires — a -route registration, an exported renderer, a management command, a call into the -shared core. It never asks the adapter to self-report (a self-report is the -thing that drifts), and it never reads a parity table (that would be circular). - -The single invariant: **a probe goes green only when the wiring exists.** You -cannot flip a cell by editing prose, a docstring, or a README — only by adding -the route / module / binding the probe looks for. - -Two facts learned by building this and watching it lie, now load-bearing: - -1. Comments are the false-positive engine. A doc-comment that says "the JS side - re-wraps it into a Promise.reject" made an auth-enforcement probe pass on the - word `reject`. So source is comment-stripped per language before matching, - and patterns target code identifiers (`MizanError::Unauthorized`, a route - macro, an exported function name), never prose-able words. - -2. AFI-common logic lives in the shared core; the adapter rides it. FastAPI's - JWT/MWT support *is* `mizan_core.auth.authenticate` — there is no `jwt` - literal in the FastAPI adapter, because the adapter delegates. Axum's KDL IR - lives in `cores/mizan-rust`, not the axum crate. So capabilities that are - genuinely core-provided (`jwt`, `mwt`, `ir_export`) probe the BACKEND scope - (adapter + its language core); capabilities the adapter must wire to its own - transport (the header on the response, the route, the manifest command, the - WebSocket handler) probe the ADAPTER scope only. - -Depth. These are *source-surface* probes: they confirm the artifact is present, -not that it behaves correctly under load. Runtime-behavior depth (mount, drive, -assert the wire bytes) is owed per adapter and lands with each gap's closure — -the runtime probe is written alongside the wiring it verifies. Surface-presence -is the floor that makes de-scope impossible; runtime is the ceiling each closure -raises, and the gap between them is named here, not hidden. - -`partial` (◑) is a real state: Axum declares `Transport::Websocket` in the IR -but routes no handler; mizan-ts decodes a JWT but mints none. It renders ◑ — and -the conformance suite still treats it as RED, because the suite asserts -`state == "pass"`. ◑ is visible honesty, never a shippable-green hiding place. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from functools import lru_cache -from pathlib import Path -from typing import Callable, Literal - -from manifest import Adapter - -REPO_ROOT = Path(__file__).resolve().parents[2] -BACKENDS = REPO_ROOT / "backends" -CORES = REPO_ROOT / "cores" - -# Each adapter's own source root. Probes scoped "adapter" read only here. -ADAPTER_ROOTS: dict[str, Path] = { - "django": BACKENDS / "mizan-django" / "src" / "mizan", - "fastapi": BACKENDS / "mizan-fastapi" / "src" / "mizan_fastapi", - "rust_axum": BACKENDS / "mizan-rust-axum" / "src", - "tauri": BACKENDS / "mizan-tauri" / "src", - "typescript": BACKENDS / "mizan-ts" / "src", -} - -# The shared core each language's adapters ride. Probes scoped "backend" read -# the adapter root PLUS these — that is where core-provided AFI-common logic -# (token minting, KDL IR) actually lives. -CORE_ROOTS: dict[str, list[Path]] = { - "python": [CORES / "mizan-python" / "src" / "mizan_core"], - "rust": [CORES / "mizan-rust" / "src", CORES / "mizan-rust-macros" / "src"], - "typescript": [], # mizan-ts is self-contained; no shared core -} - -_SOURCE_EXTS = {".py", ".rs", ".ts", ".tsx"} -_SKIP_DIRS = {"node_modules", "target", "__pycache__", "dist", ".venv"} - - -State = Literal["pass", "partial", "fail"] - - -@dataclass(frozen=True) -class ProbeResult: - state: State - detail: str - - -# ─── Source collection + comment stripping ──────────────────────────────────── - - -def _strip_comments(text: str, language: str) -> str: - """Remove comments so prose can't satisfy a code-artifact pattern. - - Imperfect by design (a `#` inside a string may be clipped); the patterns are - code identifiers that live outside strings, and the cost of a rare false - strip is far below the cost of a comment-driven false `pass`. - """ - if language in ("rust", "typescript"): - text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) # block /* */ (incl. /** */) - text = re.sub(r"(? str: - chunks: list[str] = [] - for root in roots: - if not root.exists(): - continue - for path in sorted(root.rglob("*")): - if not (path.is_file() and path.suffix in _SOURCE_EXTS): - continue - if any(part in _SKIP_DIRS for part in path.parts): - continue - chunks.append(_strip_comments(path.read_text(encoding="utf-8", errors="replace"), language)) - return "\n".join(chunks) - - -@lru_cache(maxsize=None) -def _adapter_text(adapter_id: str, language: str) -> str: - return _read_roots((ADAPTER_ROOTS[adapter_id],), language) - - -@lru_cache(maxsize=None) -def _backend_text(adapter_id: str, language: str) -> str: - roots = (ADAPTER_ROOTS[adapter_id], *CORE_ROOTS.get(language, [])) - return _read_roots(tuple(roots), language) - - -def _adapter(a: Adapter) -> str: - return _adapter_text(a.id, a.language) - - -def _backend(a: Adapter) -> str: - return _backend_text(a.id, a.language) - - -def _has_path(adapter_id: str, *relparts: str) -> bool: - return ADAPTER_ROOTS[adapter_id].joinpath(*relparts).exists() - - -def _hit(text: str, pattern: str) -> bool: - return re.search(pattern, text) is not None - - -def _wired(found: bool, what: str) -> ProbeResult: - return ProbeResult("pass", f"wired: {what}") if found else ProbeResult("fail", f"missing: {what}") - - -# ─── Per-capability detection ───────────────────────────────────────────────── -# -# Patterns are grounded in artifacts read directly from each adapter: -# django urls.py `path("call/", ...)`, channels/ ssr/ shapes/ forms/ dirs -# fastapi router.py `@router.post("/call/")`, header insert, /session/ -# axum handlers.rs `function_call`, `compute_invalidation`, no header/cache -# tauri lib.rs `mizan_invoke`, Envelope::Call/Fetch, no auth/cache/ws -# ts dispatch.ts `handleMutationCall`, checkAuth, token.ts decode-only - - -def _probe_rpc_call(a: Adapter) -> ProbeResult: - pat = { - "python": r'path\("call/|/call/|function_call', - "rust": r"function_call|mizan_invoke|Envelope::Call", - "typescript": r"handleMutationCall", - }[a.language] - return _wired(_hit(_adapter(a), pat), "RPC call dispatch") - - -def _probe_context_bundle(a: Adapter) -> ProbeResult: - pat = { - "python": r"context_fetch|/ctx/|ctx/<", - "rust": r"context_fetch|handle_fetch|Envelope::Fetch", - "typescript": r"handleContextFetch", - }[a.language] - return _wired(_hit(_adapter(a), pat), "named-context bundle fetch") - - -def _probe_invalidate_body(a: Adapter) -> ProbeResult: - pat = { - "python": r'"invalidate"|invalidate=|\binvalidate\b', - "rust": r"invalidate|InvalidationTarget", - "typescript": r"\.invalidate\b|invalidate:", - }[a.language] - return _wired(_hit(_adapter(a), pat), "`invalidate` key in response body") - - -def _probe_invalidate_header(a: Adapter) -> ProbeResult: - return _wired(_hit(_adapter(a), r"X-Mizan-Invalidate"), "`X-Mizan-Invalidate` header emission") - - -def _probe_invalidate_autoscope(a: Adapter) -> ProbeResult: - # The adapter wires auto-scoping by routing through the shared resolver. - pat = { - "python": r"resolve_invalidation|dispatch_call|dispatch_context", - "rust": r"compute_invalidation", - "typescript": r"resolveInvalidation", - }[a.language] - return _wired(_hit(_adapter(a), pat), "three-tier invalidation auto-scoping (shared resolver)") - - -def _probe_registration(a: Adapter) -> ProbeResult: - pat = { - "python": r"\bregister\b|get_function|registry", - "rust": r"FUNCTIONS|lookup_function|linkme", - "typescript": r"getFunction|\bregister\b|registry", - }[a.language] - return _wired(_hit(_adapter(a), pat), "function discovery / registration") - - -def _probe_ir_export(a: Adapter) -> ProbeResult: - # Core-provided: python build_ir/export cmd (adapter), rust kdl.rs/ir.rs (core). - if a.language == "python": - return _wired(_hit(_adapter(a), r"build_ir|export_mizan_ir"), "KDL IR export") - if a.language == "rust": - return _wired(_hit(_backend(a), r"to_kdl|emit_kdl|fn build_ir|kdl::"), "KDL IR export (rust core)") - # TypeScript: emits the edge manifest (JSON), but no codegen KDL IR (note 8). - if _hit(_adapter(a), r"to_kdl|emitKdl|buildIr|\bkdl\b"): - return ProbeResult("pass", "wired: KDL IR emitter") - return ProbeResult("fail", "missing: KDL IR emitter (manifest.ts emits edge JSON, not codegen IR)") - - -def _probe_upload(a: Adapter) -> ProbeResult: - pat = { - "python": r"bind_uploads|UploadedFile|multipart", - "rust": r"Multipart|multipart|bind_uploads", - "typescript": r"FormData|multipart", - }[a.language] - return _wired(_hit(_adapter(a), pat), "multipart / Upload-type dispatch binding") - - -def _probe_auth_enforcement(a: Adapter) -> ProbeResult: - if a.language == "python": - return _wired(_hit(_adapter(a), r"enforce_auth|authenticate\(|authguard"), "auth= enforcement") - if a.language == "typescript": - return _wired(_hit(_adapter(a), r"checkAuth|authDenial|AuthDenial"), "auth= enforcement") - # Rust adapters (axum, tauri): dispatch must reject on auth=. - if _hit(_adapter(a), r"Unauthorized|Forbidden|enforce_auth"): - return ProbeResult("pass", "wired: auth enforcement in dispatch") - if _hit(_backend(a), r"\bauth\b|is_private|\bprivate\b"): - return ProbeResult("partial", "auth/private carried in FunctionSpec; dispatch does not reject") - return ProbeResult("fail", "missing: auth= enforcement") - - -def _probe_origin_cache(a: Adapter) -> ProbeResult: - pat = { - "python": r"CacheOrchestrator|derive_cache_key|cfg\.cache|cache_get|cache_put", - "rust": r"derive_cache_key|CacheBackend|origin_cache", - "typescript": r"cacheGet|cachePut|deriveCacheKey", - }[a.language] - return _wired(_hit(_backend(a), pat), "origin-side HMAC cache") - - -def _probe_edge_manifest(a: Adapter) -> ProbeResult: - pat = { - "python": r"export_edge_manifest|generate_edge_manifest|edge_manifest", - "rust": r"edge_manifest|generate_manifest", - "typescript": r"generateManifest|EdgeManifest", - }[a.language] - return _wired(_hit(_adapter(a), pat), "edge manifest export") - - -def _probe_psr(a: Adapter) -> ProbeResult: - return _wired(_hit(_adapter(a), r"render_strategy|renderStrategy"), "PSR render_strategy") - - -def _probe_session_init(a: Adapter) -> ProbeResult: - pat = { - "python": r"session/|session_init", - "rust": r"session_init|/session/", - "typescript": r"sessionInit|/session/", - }[a.language] - return _wired(_hit(_adapter(a), pat), "session / CSRF init endpoint") - - -def _probe_websocket(a: Adapter) -> ProbeResult: - if a.id == "django": - return _wired(_has_path(a.id, "channels"), "Django Channels consumer") - if a.id == "fastapi": - return _wired(_hit(_adapter(a), r"WebSocket|@router\.websocket|websocket_route"), "FastAPI WebSocket route") - if a.id == "rust_axum": - if _hit(_adapter(a), r"WebSocketUpgrade|on_upgrade|ws_handler"): - return ProbeResult("pass", "wired: Axum WebSocket handler") - if _hit(_backend(a), r"Websocket|WebSocket"): - return ProbeResult("partial", "Transport::Websocket declared in IR; no Axum handler routed") - return ProbeResult("fail", "missing: WebSocket transport") - if a.id == "tauri": - return _wired(_hit(_adapter(a), r"subscription|Subscribe|emit_to|Channel<"), "IPC subscription channel") - return _wired(_hit(_adapter(a), r"WebSocket|websocket"), "WebSocket transport") - - -def _probe_ssr_bridge(a: Adapter) -> ProbeResult: - # Uniform, location-independent: the SSR subprocess bridge is single-sourced - # (Python adapters ride `mizan_core.ssr.SSRBridge`); a capability "pass" means - # the ADAPTER invokes it — references the bridge / its renderer — over its own - # surface, not that a `bridge.py` lives at a fixed path. So the check is the - # same for every adapter: an invocation of the SSR renderer in adapter source. - return _wired(_hit(_adapter(a), r"SSRBridge|renderToString|ssr_bridge"), "SSR bridge (subprocess renderer)") - - -def _probe_jwt(a: Adapter) -> ProbeResult: - # Core-provided for Python (mizan_core.auth); TS has decode-only helpers. - if a.language == "python": - return _wired(_hit(_backend(a), r"\bjwt\b|JWT|def authenticate"), "JWT auth (rides mizan_core.auth)") - if a.id == "typescript": - if _hit(_adapter(a), r"signJwt|mintJwt|createJwt|encodeJwt"): - return ProbeResult("pass", "wired: JWT mint + verify") - if _hit(_adapter(a), r"decodeJwt|decodeJwtBearer"): - return ProbeResult("partial", "decode-only helper; mints no tokens") - return ProbeResult("fail", "missing: JWT auth") - return _wired(_hit(_backend(a), r"\bjwt\b|JWT"), "JWT auth (access / refresh)") - - -def _probe_mwt(a: Adapter) -> ProbeResult: - if a.language == "python": - return _wired(_hit(_backend(a), r"\bmwt\b|MWT|X-Mizan-Token"), "MWT (edge identity token)") - if a.id == "typescript": - if _hit(_adapter(a), r"signMwt|mintMwt|createMwt"): - return ProbeResult("pass", "wired: MWT mint") - if _hit(_adapter(a), r"decodeMwt|identityFromMwt"): - return ProbeResult("partial", "decode-only helper; mints no tokens") - return ProbeResult("fail", "missing: MWT") - return _wired(_hit(_backend(a), r"\bmwt\b|MWT"), "MWT (edge identity token)") - - -def _probe_shapes(a: Adapter) -> ProbeResult: - if a.id == "django": - return _wired(_has_path(a.id, "shapes"), "django-readers Shapes binding") - pat = { - "python": r"django_readers|QuerysetProjection|shapes\.", - "rust": r"shapes::|QueryProjection", - "typescript": r"QueryProjection|shapesBinding", - }[a.language] - return _wired(_hit(_adapter(a), pat), "typed query-projection binding") - - -def _probe_forms(a: Adapter) -> ProbeResult: - if a.id == "django": - return _wired(_has_path(a.id, "forms"), "Django Forms binding (schema / validate / submit)") - if a.language == "rust": - if _hit(_adapter(a), r"form_submit|form_validate|FormRole::Submit"): - return ProbeResult("pass", "wired: form validate/submit endpoint") - if _hit(_backend(a), r"is_form|form_role|FormRole"): - return ProbeResult("partial", "is_form/form_role carried; no validate/submit endpoint") - return ProbeResult("fail", "missing: forms") - pat = { - "python": r"FormSchema|form_role|forms\.", - "typescript": r"FormSchema|formRole|formSubmit", - }[a.language] - return _wired(_hit(_adapter(a), pat), "forms (schema / validate / submit)") - - -PROBES: dict[str, Callable[[Adapter], ProbeResult]] = { - "rpc_call": _probe_rpc_call, - "context_bundle": _probe_context_bundle, - "invalidate_body": _probe_invalidate_body, - "invalidate_header": _probe_invalidate_header, - "invalidate_autoscope": _probe_invalidate_autoscope, - "registration": _probe_registration, - "ir_export": _probe_ir_export, - "upload": _probe_upload, - "auth_enforcement": _probe_auth_enforcement, - "origin_cache": _probe_origin_cache, - "edge_manifest": _probe_edge_manifest, - "psr": _probe_psr, - "session_init": _probe_session_init, - "websocket": _probe_websocket, - "ssr_bridge": _probe_ssr_bridge, - "jwt": _probe_jwt, - "mwt": _probe_mwt, - "shapes": _probe_shapes, - "forms": _probe_forms, -} - - -def run_probe(capability_id: str, adapter: Adapter) -> ProbeResult: - """Run the probe for one (capability, adapter) pair.""" - probe = PROBES.get(capability_id) - if probe is None: - raise KeyError( - f"No probe registered for capability '{capability_id}'. Every capability " - f"in manifest.CAPABILITIES owes a probe — add one to PROBES." - ) - return probe(adapter) diff --git a/tests/afi/rust_app/Cargo.lock b/tests/afi/rust_app/Cargo.lock index 0c54564..52d5ef5 100644 --- a/tests/afi/rust_app/Cargo.lock +++ b/tests/afi/rust_app/Cargo.lock @@ -39,7 +39,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", "bytes", "futures-util", "http", @@ -51,7 +50,6 @@ dependencies = [ "matchit", "memchr", "mime", - "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -59,10 +57,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -90,33 +86,12 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -129,51 +104,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "errno" version = "0.3.14" @@ -208,23 +138,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -238,49 +151,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-macro", - "futures-sink", "futures-task", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.4.0" @@ -442,10 +323,7 @@ name = "mizan-axum" version = "0.1.0" dependencies = [ "axum", - "base64", - "futures-util", "mizan-core", - "multer", "serde", "serde_json", "tokio", @@ -458,13 +336,10 @@ name = "mizan-core" version = "0.1.0" dependencies = [ "async-trait", - "base64", - "hmac", "linkme", "mizan-macros", "serde", "serde_json", - "sha2", ] [[package]] @@ -477,23 +352,6 @@ dependencies = [ "syn", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -535,15 +393,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -562,36 +411,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -685,28 +504,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -739,18 +536,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -768,26 +553,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio" version = "1.52.3" @@ -816,18 +581,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tower" version = "0.5.3" @@ -892,48 +645,12 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -955,26 +672,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "zerocopy" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/tests/afi/test_capability_parity.py b/tests/afi/test_capability_parity.py deleted file mode 100644 index 0f60ed5..0000000 --- a/tests/afi/test_capability_parity.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -AFI capability parity — the runtime/surface conformance gate. - -`test_codegen_parity.py` gates that the three backends emit byte-identical KDL. -That is necessary but narrow: it proves the IR agrees, not that an adapter -actually *implements* the capabilities the IR describes. The vacuum left by -"IR-shape only" is exactly where parity drifted — an adapter that never wired -SSR or WebSocket got its gap relabelled "Django-only" or "out of scope," and -nothing in the suite objected. - -This module closes that vacuum. It parametrizes over every (capability, -applicable-adapter) pair drawn from `manifest.py` and asserts the adapter -actually wires the capability (`probes.py`). It is designed to be RED wherever a -gap is real — each failure names one owed binding. That redness is not a broken -build; it is the board of owed work, itemized and loud, that the prior prose -table hid behind false-green. A gap turns green by being *wired*, never by being -*described*. - -Applicability is derived in `manifest.applies()` from the adapter's declared -transport, so a capability that simply does not exist over a transport (header -invalidation over Tauri IPC) is not parametrized here at all — it is a "—" in -the generated table, computed, not a verdict anyone typed. -""" - -from __future__ import annotations - -import pytest - -from manifest import ADAPTERS, CAPABILITIES, CAPABILITIES_BY_ID, PROBES_REQUIRED, applies -from probes import PROBES, run_probe - - -def _applicable_pairs() -> list[tuple[str, str, str]]: - """(capability_id, adapter_id, test_id) for every pair the protocol applies to.""" - pairs: list[tuple[str, str, str]] = [] - for cap in CAPABILITIES: - for adapter in ADAPTERS: - if applies(cap, adapter): - pairs.append((cap.id, adapter.id, f"{cap.id}::{adapter.id}")) - return pairs - - -_PAIRS = _applicable_pairs() - - -@pytest.mark.parametrize( - "capability_id,adapter_id", - [(c, a) for c, a, _ in _PAIRS], - ids=[tid for _, _, tid in _PAIRS], -) -def test_adapter_wires_capability(capability_id: str, adapter_id: str) -> None: - """The adapter must wire the AFI-common capability the protocol declares. - - A failure here is one owed binding, not a regression. The message names the - capability, the adapter, and what the probe could not find — that string is - the gap's specification until someone closes it by wiring the artifact. - """ - adapter = next(a for a in ADAPTERS if a.id == adapter_id) - cap = CAPABILITIES_BY_ID[capability_id] - result = run_probe(capability_id, adapter) - - assert result.state == "pass", ( - f"AFI parity gap — {adapter.title} does not wire '{cap.title}'.\n" - f" probe: {result.detail}\n" - f" state: {result.state}" - + (" (◑ partial — declared/stubbed but not complete)" if result.state == "partial" else "") - + f"\n This capability is AFI-common (manifest tier: {cap.tier.value}); every " - f"adapter owes a binding. Close it by wiring the artifact the probe looks for — " - f"not by editing a table." - ) - - -# ─── Meta-conformance: the manifest and the probes must stay in lockstep ─────── - - -def test_every_capability_has_a_probe() -> None: - """No capability may be declared without a probe — else it is unverifiable - and silently 'passes' by never being checked, recreating the original hole.""" - missing = [c.id for c in CAPABILITIES if c.id not in PROBES] - assert not missing, ( - f"Capabilities declared in manifest.py with no probe in probes.py: {missing}. " - f"An unprobed capability is an un-gated parity claim — exactly the drift this " - f"suite exists to prevent." - ) - - -def test_no_orphan_probes() -> None: - """No probe may exist for a capability the manifest doesn't declare — that - would be dead detection code drifting from the surface it claims to check.""" - orphans = [pid for pid in PROBES if pid not in CAPABILITIES_BY_ID] - assert not orphans, ( - f"Probes in probes.py with no matching capability in manifest.py: {orphans}." - ) - - -def test_probe_count_matches_required() -> None: - """Sanity pin: the manifest's own count of required probes equals the probe set.""" - assert len(PROBES) == PROBES_REQUIRED, ( - f"probes.py defines {len(PROBES)} probes; manifest expects {PROBES_REQUIRED}." - ) - - -def test_readme_parity_table_is_current() -> None: - """The README parity table is generated output; a hand-edit must fail here. - - This is the lock that makes the original lie inexpressible. The table can no - longer be edited to read 'Django-only' — it is spliced from the probe results - by `parity_table.py`, and this test asserts the committed block matches a fresh - regeneration. Drift → red → `make parity-table`. - """ - import parity_table - - text = parity_table.README.read_text(encoding="utf-8") - regenerated = parity_table._splice(text, parity_table.generate_block()) - assert regenerated == text, ( - "README parity table is stale or hand-edited. It is generated from the " - "conformance probes — run `make parity-table` to regenerate it." - )