Compare commits
5 Commits
b41f469bbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587be8c4ab | |||
| ae684a36cb | |||
| adcc027894 | |||
| 6c5f6f1fba | |||
| 58d2cb2848 |
@@ -1,4 +1,4 @@
|
|||||||
name: Publish Django package to Gitea registry
|
name: Publish Django package to PyPI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Publish React package to Gitea registry
|
name: Publish React package to npm
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,6 +34,3 @@ examples/django-react-site/harness/test-results/
|
|||||||
.env.*
|
.env.*
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|
||||||
# Agent worktrees (transient scratch — never tracked)
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
473
CLAUDE.md
473
CLAUDE.md
@@ -1,473 +0,0 @@
|
|||||||
# Mizan — Technical Reference
|
|
||||||
|
|
||||||
## What Mizan Is
|
|
||||||
|
|
||||||
Mizan is an Application Framework Interface (AFI). One decorator on a server function. Typed client generated. Invalidation automatic. Caching protocol-driven. SSR via subprocess.
|
|
||||||
|
|
||||||
Django + React ships first. The protocol is language-agnostic (proven by mizan-ts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Package Layout
|
|
||||||
|
|
||||||
Tree organized by role. Per-framework adapters wrap a single shared kernel; codegen targets the adapter.
|
|
||||||
|
|
||||||
```
|
|
||||||
backends/ server protocol adapters
|
|
||||||
mizan-django/ Django adapter
|
|
||||||
mizan-fastapi/ FastAPI adapter (RPC + context + invalidation; AFI-common scope)
|
|
||||||
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
|
|
||||||
mizan-rust-axum/ Rust/Axum adapter (server-side substrate; three-way parity)
|
|
||||||
mizan-tauri/ Tauri-as-Mizan-backend substrate
|
|
||||||
frontends/ client kernel + per-framework adapters + transports
|
|
||||||
mizan-base/ framework-agnostic kernel; owns data, status, error; adapters subscribe
|
|
||||||
mizan-react/ React contexts + hooks over the kernel
|
|
||||||
mizan-vue/ Vue composables over the kernel (codegen target; runtime package unimplemented)
|
|
||||||
mizan-svelte/ Svelte stores over the kernel (codegen target; runtime package unimplemented)
|
|
||||||
mizan-rust/ Rust kernel (PyO3 bridge; consumed by the Rust codegen's python target)
|
|
||||||
mizan-tauri-transport/ Tauri IPC transport for the kernel
|
|
||||||
mizan-webview-transport/ VSCode-webview transport for the kernel
|
|
||||||
mizan-webview-channels/ webview channel transport
|
|
||||||
cores/ shared language-level primitives
|
|
||||||
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
|
|
||||||
mizan-rust/ shared Rust primitives (IR, KDL, registry, graph-check)
|
|
||||||
mizan-rust-macros/ proc-macros for the Rust backend/kernel
|
|
||||||
protocol/ protocol-level tooling
|
|
||||||
mizan-codegen/ the codegen — a Rust binary; reads KDL IR, emits typed clients per target
|
|
||||||
mizan-generate/ thin npm launcher around the compiled mizan-codegen binary
|
|
||||||
workers/ runtime workers / bridges
|
|
||||||
mizan-ssr/ Bun subprocess used by the Django template backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Three Protocols
|
|
||||||
|
|
||||||
### 1. RPC Protocol (Anti-REST)
|
|
||||||
|
|
||||||
No resources. No CRUD. Functions in, results out.
|
|
||||||
|
|
||||||
**Context fetch (reads):**
|
|
||||||
```
|
|
||||||
GET /api/mizan/ctx/<context_name>/?param1=val1¶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, 'components/Hello.tsx', props)` — the template name is a `.tsx`/`.jsx` **file path** (resolved against `DIRS`), not a component name — calls a persistent Bun subprocess that runs `renderToString`.
|
|
||||||
|
|
||||||
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
|
|
||||||
|
|
||||||
**The Bun worker protocol** — JSON-RPC over stdin/stdout. The worker `import()`s the file and renders it (no component registry):
|
|
||||||
```
|
|
||||||
→ {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {"name": "World"}}}
|
|
||||||
← {"id": 1, "html": "<div>...</div>"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Worker stays alive across requests. Django's `SSRBridge` manages the subprocess lifecycle with thread-safe request correlation via message IDs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The @client Decorator — Full API
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mizan import client, ReactContext, GlobalContext
|
|
||||||
|
|
||||||
UserContext = ReactContext('user')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|-----------|------|---------|-------------|
|
|
||||||
| `context` | `ReactContext \| str \| False` | `False` | Named context for grouping. `False` = standalone function. |
|
|
||||||
| `affects` | `ReactContext \| str \| list` | `None` | What this mutation invalidates. Mutually exclusive with `context`. |
|
|
||||||
| `private` | `bool` | `False` | Not client-callable. No RPC endpoint. No codegen. Still in invalidation graph. |
|
|
||||||
| `route` | `str \| None` | `None` | Mizan-owned URL pattern for view-path functions. |
|
|
||||||
| `methods` | `list[str] \| None` | `None` | HTTP methods for route. Default: `['GET']` for context, `['POST']` for mutation. |
|
|
||||||
| `auth` | `bool \| str \| callable \| None` | `None` | Auth requirement: `True`, `'staff'`, `'superuser'`, or `callable(request) -> bool`. |
|
|
||||||
| `websocket` | `bool` | `False` | Enable WebSocket RPC transport. |
|
|
||||||
| `rev` | `int` | `0` | Cache revision. Increment to bust cached entries on deploy. |
|
|
||||||
| `cache` | `int \| False` | (default) | Cache TTL hint. `False` = never cache. Integer = TTL seconds. |
|
|
||||||
|
|
||||||
### Usage Patterns
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Global context — auto-mounted at root, SSR-hydrated
|
|
||||||
@client(context=GlobalContext)
|
|
||||||
def current_user(request) -> UserShape:
|
|
||||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
|
||||||
|
|
||||||
# Named context — bundled GET, generates typed hooks
|
|
||||||
@client(context=UserContext)
|
|
||||||
def user_profile(request, user_id: int) -> UserShape:
|
|
||||||
return UserShape.query(lambda qs: qs.filter(pk=user_id))[0]
|
|
||||||
|
|
||||||
@client(context=UserContext)
|
|
||||||
def user_orders(request, user_id: int) -> list[OrderShape]:
|
|
||||||
return OrderShape.query(lambda qs: qs.filter(user_id=user_id))
|
|
||||||
|
|
||||||
# Mutation — auto-scoped invalidation (user_id matches)
|
|
||||||
@client(affects=UserContext)
|
|
||||||
def update_profile(request, user_id: int, name: str) -> dict:
|
|
||||||
request.user.name = name
|
|
||||||
request.user.save()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
# Function-level affects — only user_profile refetches
|
|
||||||
@client(affects='user_profile')
|
|
||||||
def update_name(request, user_id: int, name: str) -> dict:
|
|
||||||
...
|
|
||||||
|
|
||||||
# View-path context — registered in invalidation graph, no codegen
|
|
||||||
@client(context=UserContext, route='/profile/<user_id>/')
|
|
||||||
def profile_page(request, user_id: int) -> HttpResponse:
|
|
||||||
return render(request, 'profile.html', {...})
|
|
||||||
|
|
||||||
# View-path mutation — invalidation via header on the redirect
|
|
||||||
@client(affects=UserContext, route='/profile/<user_id>/update/', methods=['POST'])
|
|
||||||
def update_profile_view(request, user_id: int) -> HttpResponse:
|
|
||||||
form = ProfileForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect(f'/profile/{user_id}/')
|
|
||||||
|
|
||||||
# Private webhook — not client-callable, emits invalidation
|
|
||||||
@client(affects='subscription', private=True, route='/webhooks/stripe/', methods=['POST'])
|
|
||||||
def stripe_webhook(request) -> HttpResponse:
|
|
||||||
event = json.loads(request.body)
|
|
||||||
process_stripe_event(event)
|
|
||||||
return HttpResponse(status=200)
|
|
||||||
|
|
||||||
# Auth guards
|
|
||||||
@client(auth=True)
|
|
||||||
def secret(request) -> dict: ...
|
|
||||||
|
|
||||||
@client(auth='staff')
|
|
||||||
def admin_action(request) -> dict: ...
|
|
||||||
|
|
||||||
@client(auth=lambda req: req.user.email.endswith('@company.com'))
|
|
||||||
def internal_tool(request) -> dict: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### _meta Dict Structure
|
|
||||||
|
|
||||||
After decoration, the function class has `_meta` with these possible keys:
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"context": "user", # context name string (if context=)
|
|
||||||
"affects": [ # normalized affects targets (if affects=)
|
|
||||||
{"type": "context", "name": "user"},
|
|
||||||
{"type": "function", "name": "user_profile", "context": "user"},
|
|
||||||
],
|
|
||||||
"private": True, # if private=True
|
|
||||||
"route": "/webhooks/stripe/", # if route=
|
|
||||||
"methods": ["POST"], # if route= (defaults applied)
|
|
||||||
"view_path": True, # if return type is HttpResponse
|
|
||||||
"websocket": True, # if websocket=True
|
|
||||||
"auth": "required", # "required" | "staff" | "superuser" | callable
|
|
||||||
"rev": 3, # if rev=
|
|
||||||
"cache": 60, # if cache=
|
|
||||||
"form": True, # if form function
|
|
||||||
"form_name": "contact", # form name
|
|
||||||
"form_role": "schema", # "schema" | "validate" | "submit"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cache System
|
|
||||||
|
|
||||||
### Required Settings
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
MIZAN_CACHE_SECRET = "your-32-byte-hmac-signing-key" # Required for cache
|
|
||||||
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0" # Required for cache
|
|
||||||
```
|
|
||||||
|
|
||||||
Both must be set. If either is missing, caching is disabled with a warning.
|
|
||||||
|
|
||||||
### HMAC Key Derivation
|
|
||||||
|
|
||||||
Cache keys are derived from HMAC-SHA256 over a JSON-canonical form:
|
|
||||||
|
|
||||||
```python
|
|
||||||
derive_cache_key(secret, context, params, user_id=None, rev=0) -> str
|
|
||||||
```
|
|
||||||
|
|
||||||
**Canonical form** (the HMAC message):
|
|
||||||
```json
|
|
||||||
{"c":"user","p":{"user_id":"5"},"r":0}
|
|
||||||
```
|
|
||||||
With optional `"u":"5"` for user-scoped entries.
|
|
||||||
|
|
||||||
- `c` = context name
|
|
||||||
- `p` = sorted params dict (all values stringified)
|
|
||||||
- `r` = revision number
|
|
||||||
- `u` = user ID (for auth-scoped cache entries)
|
|
||||||
|
|
||||||
**Key format:** `ctx:{context}:{hmac_hex}`
|
|
||||||
- Example: `ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6`
|
|
||||||
|
|
||||||
**Cross-language conformance:** The TypeScript adapter (`mizan-ts/src/cache/keys.ts`) produces identical keys for identical inputs. Pin tests verify this.
|
|
||||||
|
|
||||||
### Cache Operations
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mizan.cache import cache_get, cache_put, cache_purge
|
|
||||||
|
|
||||||
# Store
|
|
||||||
cache_put(secret, backend, "user", {"user_id": "5"}, b'{"name":"Ryth"}')
|
|
||||||
|
|
||||||
# Retrieve
|
|
||||||
data = cache_get(secret, backend, "user", {"user_id": "5"})
|
|
||||||
|
|
||||||
# Scoped purge (recomputes HMAC, deletes one key)
|
|
||||||
cache_purge(backend, "user", params={"user_id": "5"}, secret=secret)
|
|
||||||
|
|
||||||
# Broad purge (SCAN by prefix "ctx:user:*")
|
|
||||||
cache_purge(backend, "user")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backends
|
|
||||||
|
|
||||||
**MemoryCache** — dict-based, for testing. No persistence.
|
|
||||||
|
|
||||||
**RedisCache** — production backend.
|
|
||||||
- Connection pooling (50 max connections)
|
|
||||||
- 24h default TTL safety net
|
|
||||||
- Key prefix: `mizan:` (configurable)
|
|
||||||
- `delete_by_prefix` uses Redis SCAN (1000 keys per batch)
|
|
||||||
- `delete` uses UNLINK (non-blocking)
|
|
||||||
|
|
||||||
### Cache Integration in Dispatch
|
|
||||||
|
|
||||||
`context_fetch_view` checks origin-side cache before executing functions. On cache miss, executes functions and stores the result. On mutation, purges affected cache entries based on the invalidation targets.
|
|
||||||
|
|
||||||
All HTTP responses emit `Cache-Control: no-store`. Origin-side caching is internal — the HTTP layer never caches at the CDN. Edge caching is managed by Mizan Edge (closed-source Cloudflare Workers) which uses the manifest and MWT tokens.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MWT (Mizan Web Token) and JWT
|
|
||||||
|
|
||||||
### Two Token Systems
|
|
||||||
|
|
||||||
**JWT** — standard user authentication tokens. Access + refresh pair. Session-tied for revocation.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
JWT_PRIVATE_KEY = "your-secret-key" # Required
|
|
||||||
JWT_ALGORITHM = "HS256" # Default, or RS256 for asymmetric
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRES_IN = 604800 # 7 days
|
|
||||||
JWT_VALIDATE_SESSION = True # Check session exists on use
|
|
||||||
```
|
|
||||||
|
|
||||||
JWT claims: `sub` (user ID), `sid` (session key), `staff`, `super`, `type` (access/refresh), `iat`, `exp`.
|
|
||||||
|
|
||||||
Session validation: on every JWT use, checks that the session still exists. Logging out destroys the session → immediately revokes all tokens tied to it.
|
|
||||||
|
|
||||||
**MWT** — Mizan Web Token. Protocol-owned identity for Edge cache keying. Separate secret from JWT and cache.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
MIZAN_MWT_SECRET = "your-mwt-signing-key" # Separate from JWT_PRIVATE_KEY
|
|
||||||
MIZAN_MWT_TTL = 300 # 5 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
MWT is used by Mizan Edge to derive user-scoped cache keys without exposing the cache secret to the client. The MWT carries claims that Edge needs (user identity, permissions) in a short-lived token that travels on a custom header (`X-Mizan-Token`).
|
|
||||||
|
|
||||||
### Secret Separation
|
|
||||||
|
|
||||||
Three independent secrets, each with its own blast radius:
|
|
||||||
|
|
||||||
| Secret | Setting | Purpose | Compromise Impact |
|
|
||||||
|--------|---------|---------|-------------------|
|
|
||||||
| JWT secret | `JWT_PRIVATE_KEY` | User auth tokens | Auth bypass |
|
|
||||||
| Cache secret | `MIZAN_CACHE_SECRET` | HMAC cache keys | Cache poisoning |
|
|
||||||
| MWT secret | `MIZAN_MWT_SECRET` | Edge identity tokens | Cache key spoofing |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SSR Implementation
|
|
||||||
|
|
||||||
### Django Template Backend
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
|
||||||
'DIRS': [BASE_DIR / 'frontend'],
|
|
||||||
'OPTIONS': {
|
|
||||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
|
||||||
'timeout': 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage in Views
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
def profile_page(request, user_id):
|
|
||||||
profile = get_user_profile(user_id)
|
|
||||||
return render(request, 'components/Profile.tsx', {'profile': profile})
|
|
||||||
```
|
|
||||||
|
|
||||||
`render()` calls `MizanTemplates.get_template('components/Profile.tsx')` — the name is a file path resolved to an absolute path against `DIRS` — which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC (`{file, props}`) to the Bun worker.
|
|
||||||
|
|
||||||
### SSR Bridge (bridge.py)
|
|
||||||
|
|
||||||
- Spawns `bun run <worker>` on first render
|
|
||||||
- Persistent subprocess — stays alive across requests
|
|
||||||
- JSON-RPC over stdin/stdout with message ID correlation
|
|
||||||
- Thread-safe: multiple Django workers can call `render()` concurrently
|
|
||||||
- Auto-restarts on crash
|
|
||||||
- Waits for `{"id": 0, "ready": true}` before accepting requests
|
|
||||||
|
|
||||||
### Bun Worker (worker.tsx)
|
|
||||||
|
|
||||||
- Reads newline-delimited JSON from stdin
|
|
||||||
- Resolves the component by **file path** — `import(file)` (cached) — no registry
|
|
||||||
- Calls `renderToString(createElement(Component, props))` on the imported default export
|
|
||||||
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
|
|
||||||
- Health check: `{"method": "ping"}` → `{"pong": true}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Edge Manifest
|
|
||||||
|
|
||||||
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"contexts": {
|
|
||||||
"user": {
|
|
||||||
"functions": [
|
|
||||||
{"name": "user_profile", "path": "rpc"},
|
|
||||||
{"name": "profile_page", "path": "view", "route": "/profile/<user_id>/"}
|
|
||||||
],
|
|
||||||
"endpoints": ["/api/mizan/ctx/user/"],
|
|
||||||
"params": ["user_id"],
|
|
||||||
"user_scoped": true,
|
|
||||||
"render_strategy": "dynamic_cached",
|
|
||||||
"page_routes": ["/profile/<user_id>/"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mutations": {
|
|
||||||
"update_profile": {
|
|
||||||
"affects": ["user"],
|
|
||||||
"auto_scoped_params": ["user_id"]
|
|
||||||
},
|
|
||||||
"stripe_webhook": {
|
|
||||||
"affects": ["subscription"],
|
|
||||||
"private": true,
|
|
||||||
"route": "/webhooks/stripe/",
|
|
||||||
"methods": ["POST"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**render_strategy**: `"psr"` (no user-scoped params) or `"dynamic_cached"` (user-scoped). Derived automatically from whether params overlap with `{user_id, user, owner_id, account_id}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## URL Patterns
|
|
||||||
|
|
||||||
```python
|
|
||||||
# mizan/urls.py
|
|
||||||
urlpatterns = [
|
|
||||||
path("session/", session_init_view), # GET — CSRF cookie
|
|
||||||
path("call/", function_call_view), # POST — RPC dispatch
|
|
||||||
path("ctx/<str:context_name>/", context_fetch_view), # GET — bundled context fetch
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Mounted at `/api/mizan/` by convention:
|
|
||||||
```python
|
|
||||||
urlpatterns = [
|
|
||||||
path("api/mizan/", include("mizan.urls")),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Codegen — Current State
|
|
||||||
|
|
||||||
The codegen is a **Rust binary**, `protocol/mizan-codegen/` (crate `mizan-codegen`). `protocol/mizan-generate/` is a thin npm launcher (`bin/launcher.mjs`) that shells out to the compiled binary. The IR is **KDL** — each backend emits KDL describing its functions/contexts; the binary reads it (`src/fetch.rs`, `src/ir.rs`) and emits per-target output from Askama templates (`templates/`, dispatched in `src/emit/`).
|
|
||||||
|
|
||||||
Two layers, same as before: a framework-agnostic protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types) and a per-framework adapter layer that subscribes to the `mizan-base` kernel.
|
|
||||||
|
|
||||||
**Targets** (`src/emit/`, each byte-checked by a `*_parity.rs` test):
|
|
||||||
|
|
||||||
- `react` — function/context hooks over `useSyncExternalStore`, plus the full wrapper layer: the `MizanContext` root provider (calls `configure()`, mounts the global context), `useMizan()` imperative escape hatch, and `useMutation`-backed hooks exposing `{ mutate, isPending, error }`.
|
|
||||||
- `vue`, `svelte` — composables / `readable` stores. Byte-parity-tested, but no runtime adapter package or live-backend example exercises them yet (the `mizan-vue`/`mizan-svelte` packages are unimplemented stubs).
|
|
||||||
- `channels` — WebSocket transport hooks.
|
|
||||||
- `stage1` — the framework-agnostic protocol files.
|
|
||||||
- `python`, `rust` — typed clients for the Python (PyO3) and Rust frontends.
|
|
||||||
|
|
||||||
The pre-kernel `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) still ships and is imported by the desktop example; it coexists with the generated `MizanContext`. Forms (`mizan-react/src/forms.ts`) are hand-written and consume the pre-kernel provider — a form codegen target wired to `mizanCall` is still owed. See `ISSUES.md`.
|
|
||||||
|
|
||||||
The SSR pipeline is independent of the codegen — the Bun worker resolves a component by **file path** (`import(file)` + `renderToString`), not via a registry.
|
|
||||||
107
INVARIANTS.md
Normal file
107
INVARIANTS.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Application Framework Interface Invariants
|
||||||
|
|
||||||
|
All invariants are absolute. Agents are not permitted to modify this file unless **DIRECTLY PROMPTED BY RYTH**.
|
||||||
|
|
||||||
|
If an invariant is not satisfiable by the backend's native functionality (for example, FastAPI is missing a native ORM for Shapes),
|
||||||
|
then a canonical technology must be proposed. The technology *MUST* be approved by Ryth before implementation.
|
||||||
|
|
||||||
|
## Backend Adapters
|
||||||
|
|
||||||
|
Django (python)
|
||||||
|
FastAPI (python)
|
||||||
|
Typescript (generic)
|
||||||
|
Rust/Axum (generic)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
## Frontend Adapters
|
||||||
|
|
||||||
|
React (Typescript)
|
||||||
|
Vue (Typescript)
|
||||||
|
Svelte (Typescript)
|
||||||
|
Tauri (Rust)
|
||||||
|
|
||||||
|
### Client Function RPC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
No REST endpoints.
|
||||||
|
|
||||||
|
Client functions are decorated functions (decorator or registration call at definition-site) that both receive and return HTTP & JSON compliant arguments.
|
||||||
|
The decoration mechanism must implement the full variadic or kwarg set (websocket, auth, context wiring).
|
||||||
|
|
||||||
|
### WebSocket Support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A client function declared `websocket=` is dispatched over a persistent connection rather than request/response. Server-initiated messages reach the subscribed contexts; invalidation travels the socket with the same semantics it has over HTTP.
|
||||||
|
|
||||||
|
The per-adapter transport differs — Django Channels, a native WebSocket route, a Tauri IPC subscription channel — but the declaration and the wire semantics do not. Mixing socket and non-socket transport within one context is a registration-time error.
|
||||||
|
|
||||||
|
### Named Contexts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Any string passed to `context=` is a named context. Functions sharing a context name are grouped at registration into one provider, one fetch, and one set of generated hooks — a single read request, never N round-trips. `context='global'` is the one reserved name: fetched once at the root and SSR-hydrated.
|
||||||
|
|
||||||
|
Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override. A read context is GET-dispatched and cacheable, and it is the unit a mutation invalidates.
|
||||||
|
|
||||||
|
### Mutation Invalidation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A mutation declares what it `affects=` — a context name, a function reference, or a list — and that relationship is generated into the client. On success the affected contexts refetch; on failure nothing invalidates. The developer never writes a cache key, never calls an invalidate function, never maintains a query-key map.
|
||||||
|
|
||||||
|
Invalidation auto-scopes by matching parameter name: a mutation carrying `user_id=123` invalidates the `user_id=123` entry, not the whole context.
|
||||||
|
|
||||||
|
This is the invariant that separates the AFI from typed RPC. An adapter that dispatches calls and projects shapes but leaves the client hand-writing invalidation has not satisfied it. The client holds a server-reconciled view, never a parallel source of truth.
|
||||||
|
|
||||||
|
### API Shapes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A backend adapter supports the "API Shape" feature to the fullest extent:
|
||||||
|
|
||||||
|
- ORM Integration
|
||||||
|
- Auto-diffing (Receive a list of objects, check primary keys for add/modify/delete semantics, use Django as reference)
|
||||||
|
- Backend-for-Frontend Authoring DX (Shape schema must be easily authorable near used function)
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
A function declaring `auth=` is enforced at dispatch on every adapter — the guard rejects before the function body runs, identically across transports. Authorization is a property of the declared function, carried in the IR, not middleware an adapter bolts on or omits.
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The `Upload` type is a first-class argument carried end to end — IR, codegen, and dispatch binding. Arguments are otherwise HTTP- and JSON-compliant; `Upload` is the one binary exception, bound from multipart over HTTP and from the envelope over IPC. The declaration is uniform; the transport binding is per-adapter.
|
||||||
|
|
||||||
|
### Canonical IR & Codegen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every backend adapter emits the canonical KDL IR describing its functions, contexts, types, and invalidation graph. Every frontend client is generated from that IR. No REST envelope, no OpenAPI document, no per-backend converter sits between a backend and a frontend — the IR is the only contract.
|
||||||
|
|
||||||
|
This is the invariant that collapses the backends × frontends quadratic to one adapter per stack. A backend that does not emit the IR, or a frontend not generated from it, is outside the AFI: the boundary is the IR, and nothing crosses it untyped.
|
||||||
|
|
||||||
|
### Client Kernel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Every frontend adapter is a thin idiomatic wrapper over one shared kernel. The kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — and reaches the backend through a pluggable transport (HTTP, Tauri IPC, webview channel). Framework adapters subscribe and render in their own idiom (React hooks, Vue composables, Svelte runes); codegen targets the adapter surface, never the raw kernel.
|
||||||
|
|
||||||
|
No adapter keeps its own copy of the truth. The reconciled view lives once, in the kernel.
|
||||||
|
|
||||||
|
### SSR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Server rendering is the AFI's second product, orthogonal to RPC and composable with it — either ships standalone. A function's registered render strategy renders on the server through the bridge and hydrates on the client; the contexts a page reads are SSR-hydrated at the root, so first paint carries data rather than a loading state.
|
||||||
|
|
||||||
|
## Compositions
|
||||||
|
|
||||||
|
Stdlib over the invariants above, not invariants in themselves — named so the boundary is explicit and an adapter is never marked short for lacking them as primitives:
|
||||||
|
|
||||||
|
- **Forms** — three role-tagged client functions (schema / validate / submit) plus field validation. RPC and validation composed; not its own primitive.
|
||||||
|
- **Context classes (`send` / `receive`)** — the read/write class form with Shape diffing. Named Contexts + API Shapes + Mutation Invalidation composed into one declaration; the heavy DX surface over the primitives, not a new primitive.
|
||||||
@@ -11,9 +11,8 @@ no longer exist.
|
|||||||
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
- [ ] **Vue / Svelte frontend packages are unimplemented stubs.** `frontends/mizan-vue` and `frontends/mizan-svelte` contain only a `package.json` — no `src/`. The Rust codegen emits Vue composables and Svelte stores (`src/emit/vue.rs`, `src/emit/svelte.rs`, byte-checked by `vue_svelte_parity.rs`), but there is no runtime kernel-adapter package for either and no example app exercises them against a live backend. React is the only frontend with full integration verification.
|
||||||
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
- [ ] **Svelte adapter emits Svelte 4 stores.** `src/emit/svelte.rs` generates `readable` stores from `svelte/store`. Svelte 5 `$state`/`$derived` runes are the current idiom.
|
||||||
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
- [ ] **Forms have no codegen target.** `mizan-react/src/forms.ts` (form core hooks) is hand-written and consumed via the pre-kernel `MizanProvider`; the e2e harness has its form fixtures removed. A form codegen target wired to `mizanCall` is owed.
|
||||||
- [ ] **Upload dispatch not wired for Rust/Axum + Tauri.** The `Upload` type is first-class end to end — IR (`upload` KDL node), codegen (TS `File`; the Rust target lowers it to `Vec<u8>`), kernel (auto-multipart), and dispatch+constraint binding on Django and FastAPI. The Rust/Axum and Tauri *adapters* have no upload concept at dispatch — they don't bind multipart file parts yet.
|
|
||||||
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
- [ ] **Pre-kernel MizanProvider still shipped.** `mizan-react/src/context.tsx` (~750 lines) is the pre-kernel provider, still imported by the desktop example. It coexists with the codegen-emitted `MizanContext` (which subscribes to `@mizan/base`). Migrating the desktop example onto the generated provider retires it.
|
||||||
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: cross-language stringification of un-normalized value types, and no thundering-herd / single-flight protection.
|
- [ ] **Cache module open issues.** See `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`: purge atomicity, cross-language stringification, per-param sub-index cleanup, thundering-herd protection, `cache_get`/`cache_put` argument inconsistency, RedisCache test coverage.
|
||||||
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
- [ ] **Packages missing a README.** `frontends/mizan-base` (the kernel everything imports), `protocol/mizan-codegen` (the codegen binary), `frontends/mizan-vue`, `frontends/mizan-svelte`, `frontends/mizan-rust`, `backends/mizan-ts`, `backends/mizan-rust-axum`, `cores/mizan-python`.
|
||||||
|
|
||||||
## Resolved this pass
|
## Resolved this pass
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -52,8 +52,7 @@ The surface every Mizan adapter implements.
|
|||||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | ❌ ⁸ |
|
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
||||||
| File uploads (multipart, `Upload` type) | ✅ | ✅ | ❌ ⁹ | ❌ ⁹ | — ¹⁰ |
|
|
||||||
|
|
||||||
### Edge, cache & enforcement
|
### Edge, cache & enforcement
|
||||||
|
|
||||||
@@ -61,18 +60,15 @@ Protocol transports and guarantees co-equal with the body channel in the spec.
|
|||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|:---:|:---:|
|
||||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ✅ | ❌ | — ¹ | ✅ |
|
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
||||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ✅ ¹¹ |
|
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
||||||
| Origin-side HMAC cache | ✅ | ✅ | ❌ | ❌ | ✅ |
|
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||||
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
||||||
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
||||||
|
|
||||||
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
|
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
|
||||||
> it — do not rely on `auth=` for access control on those adapters.
|
> it — do not rely on `auth=` for access control on those adapters.
|
||||||
>
|
|
||||||
> Django, FastAPI, and TypeScript share one auth/invalidation/cache implementation
|
|
||||||
> (`mizan_core` for the Python adapters; the same spec, pinned cross-language, for TS).
|
|
||||||
|
|
||||||
### Stack extensions (Django)
|
### Stack extensions (Django)
|
||||||
|
|
||||||
@@ -85,8 +81,8 @@ target stack calls for them.
|
|||||||
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
|
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
|
||||||
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
|
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
|
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
|
||||||
| JWT auth (access / refresh) ¹² | ✅ | ✅ | ❌ | ❌ | ◑ ¹³ |
|
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
| MWT (edge identity token) | ✅ | ✅ | ❌ | — | ◑ ¹³ |
|
| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
|
||||||
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
|
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
|
||||||
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
@@ -107,26 +103,9 @@ target stack calls for them.
|
|||||||
rather than fetching over HTTP.
|
rather than fetching over HTTP.
|
||||||
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||||
parity; CSRF is Django-only.
|
parity; CSRF is Django-only.
|
||||||
8. `mizan-ts` emits the Edge manifest (JSON) but has no KDL IR emitter, so it can't yet
|
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||||
feed the codegen — an unbuilt gap. A TypeScript backend still needs the generated
|
codegen source — it demonstrates the cache + invalidation protocol is
|
||||||
client (types + `callXxx`/`fetchXxx` + framework hooks); same language doesn't remove
|
language-agnostic.
|
||||||
the need for it.
|
|
||||||
9. The `mizan-codegen` crate parses the `upload` KDL node and emits the field across
|
|
||||||
targets (the Rust target lowers it to `Vec<u8>`). Multipart dispatch binding is wired
|
|
||||||
for Django and FastAPI only; the Rust/Axum and Tauri *adapters* have no upload concept
|
|
||||||
at dispatch yet.
|
|
||||||
10. The TypeScript column is the `mizan-ts` backend adapter, which has no upload
|
|
||||||
dispatch. The matching client side lives in the kernel (`@mizan/base`): `mizanCall`
|
|
||||||
auto-switches to `multipart/form-data` when any argument is a `File`.
|
|
||||||
11. `mizan-ts` dispatch now enforces `auth=` (`true`/`'staff'`/`'superuser'`/predicate)
|
|
||||||
against a host-supplied `Identity`, byte-matching the Python guard's denial messages.
|
|
||||||
12. JWT/MWT token logic is single-sourced in `mizan_core.auth`; Django and FastAPI ride
|
|
||||||
it. Session-validation (immediate-logout revocation) is Django-only — FastAPI mints
|
|
||||||
from its own credential check.
|
|
||||||
13. `mizan-ts` ships an optional `decodeMwt`/`decodeJwtBearer`/`identityFromMwt` helper
|
|
||||||
(HS256 via Node `crypto`, cross-language pin-tested against a Python-minted MWT) so a
|
|
||||||
TS edge worker can derive `Identity` from a Python-issued token. Identity source stays
|
|
||||||
host-supplied; `mizan-ts` does not mint from a session.
|
|
||||||
|
|
||||||
## Conformance
|
## Conformance
|
||||||
|
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@@ -34,11 +34,21 @@
|
|||||||
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
- [ ] **Svelte 5 runes** — the Svelte target emits Svelte 4 `readable` stores; migrate to `$state`/`$derived`.
|
||||||
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
- [ ] **Forms codegen target** — emit form clients wired to `mizanCall` from the kernel; retire the hand-written `mizan-react/src/forms.ts` and its dependence on the pre-kernel provider.
|
||||||
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
- [ ] **Desktop example onto the generated provider** — migrate `examples/django-react-desktop-app` off the pre-kernel `MizanProvider` (`mizan-react/src/context.tsx`) so it can be retired.
|
||||||
- [ ] **Cache hardening** — thundering-herd / single-flight protection, and pinning cross-language stringification of un-normalized value types (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
- [ ] **Cache hardening** — purge atomicity, per-param sub-index cleanup, thundering-herd protection, RedisCache coverage (see `backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md`).
|
||||||
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
- [ ] **Package READMEs** — `mizan-base`, `mizan-codegen`, and the other packages missing one (see `ISSUES.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Core Consolidation — Rust Binary
|
||||||
|
|
||||||
|
Move all core functionality unrelated to language introspection into the Rust binary. Other languages invoke it through FFI (PyO3 and equivalents) rather than carrying their own copy — centralizing behavior for the whole Mizan toolchain.
|
||||||
|
|
||||||
|
Language-specific core code then exists only for actual framework mechanics — registering client functions, binding Shapes to an ORM — never for behavior the binary already owns.
|
||||||
|
|
||||||
|
**SSR in the binary.** Because SSR works directly from the IR's typed schemas, the binary can drive it rather than forcing each backend adapter to author SSR by hand. That also lets the binary own SSR validation, keeping it consistent across adapters instead of each backend deriving it manually and drifting apart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mizan Cloud (closed-source)
|
## Mizan Cloud (closed-source)
|
||||||
|
|
||||||
### Mizan Edge
|
### Mizan Edge
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ from . import setup
|
|||||||
from .channels import ReactChannel
|
from .channels import ReactChannel
|
||||||
from .channels import register as register_channel
|
from .channels import register as register_channel
|
||||||
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
from .client import ComposedContext, GlobalContext, ReactContext, ServerFunction, client, compose
|
||||||
from mizan_core.upload import File, Upload, UploadedFile
|
|
||||||
|
|
||||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
# imports contenttypes, which can't happen during apps.populate()
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
@@ -165,10 +164,6 @@ __all__ = [
|
|||||||
"GlobalContext",
|
"GlobalContext",
|
||||||
"ServerFunction",
|
"ServerFunction",
|
||||||
"ComposedContext",
|
"ComposedContext",
|
||||||
# File uploads
|
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
# Setup
|
# Setup
|
||||||
"mizan_clients",
|
"mizan_clients",
|
||||||
"mizan_module",
|
"mizan_module",
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# Cache Module — Known Issues
|
# Cache Module — Known Issues
|
||||||
|
|
||||||
Open issues against the current cache implementation. The cache uses
|
Open issues against the current cache implementation. Resolved items are
|
||||||
HMAC-derived keys with **no reverse indexes** (scoped purge recomputes the key;
|
removed once their fix lands.
|
||||||
broad purge is a prefix SCAN+UNLINK), so there are no index/sub-index races to
|
|
||||||
track. Resolved items are removed once their fix lands.
|
|
||||||
|
|
||||||
## Correctness
|
## Correctness
|
||||||
|
|
||||||
|
### Purge race condition (non-atomic index operations)
|
||||||
|
`cache_purge` reads the index and deletes as separate operations. A
|
||||||
|
concurrent `cache_put` between the two steps can orphan entries. Mitigated
|
||||||
|
by AND-intersection purge semantics, but full atomicity (Lua script or
|
||||||
|
`WATCH`/`MULTI` on the Redis backend) is still owed.
|
||||||
|
|
||||||
### Cross-language stringification divergence
|
### Cross-language stringification divergence
|
||||||
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
Python `str(True)` → `"True"` vs JS `String(true)` → `"true"`. `_normalize`
|
||||||
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
canonicalizes `True`/`False`/`None` today, but the rules for the remaining
|
||||||
@@ -15,6 +19,22 @@ TypeScript HMAC keys can still diverge on an un-normalized type.
|
|||||||
|
|
||||||
## Performance / Operability
|
## Performance / Operability
|
||||||
|
|
||||||
|
### Broad purge leaves per-param sub-indexes
|
||||||
|
A broad `cache_purge(context)` deletes the entries but not the per-param
|
||||||
|
sub-indexes — a slow Redis memory leak.
|
||||||
|
|
||||||
### No thundering-herd protection
|
### No thundering-herd protection
|
||||||
Concurrent cold misses on the same key all execute and write. No
|
Concurrent cold misses on the same key all execute and write. No
|
||||||
single-flight / request-coalescing.
|
single-flight / request-coalescing.
|
||||||
|
|
||||||
|
## API shape
|
||||||
|
|
||||||
|
### cache_get / cache_put argument inconsistency
|
||||||
|
`cache_get`/`cache_put` take explicit args while the executor resolves some
|
||||||
|
inputs from module globals — two access patterns for one concern.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
### RedisCache lacks test coverage
|
||||||
|
Only `MemoryCache` is exercised by the suite. `RedisCache` (connection
|
||||||
|
pooling, TTL, SCAN/UNLINK batching, socket timeouts) is untested.
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ from pydantic import BaseModel, ValidationError
|
|||||||
|
|
||||||
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
|
||||||
from mizan_core.registry import get_function, get_context_groups
|
from mizan_core.registry import get_function, get_context_groups
|
||||||
from mizan_core.upload import UploadedFile, bind_uploads
|
|
||||||
from mizan_core import invalidation as _core_inval
|
|
||||||
from mizan_core.authguard import enforce_auth as _core_enforce_auth
|
|
||||||
from mizan_core.errors import MizanError as _CoreMizanError
|
|
||||||
from mizan.setup.settings import get_settings
|
from mizan.setup.settings import get_settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -116,14 +112,53 @@ def _check_auth_requirement(
|
|||||||
Django User (from session). Either way, no additional DB query is made
|
Django User (from session). Either way, no additional DB query is made
|
||||||
for the built-in checks. Custom callables may query DB if they choose.
|
for the built-in checks. Custom callables may query DB if they choose.
|
||||||
"""
|
"""
|
||||||
# Evaluation lives in the shared core (mizan_core.authguard); the callable
|
if auth_requirement is None:
|
||||||
# path receives the native Django request. Core raises; we render to the
|
return None
|
||||||
# Django-shim FunctionError shape the executor expects.
|
|
||||||
try:
|
user = request.user
|
||||||
_core_enforce_auth(getattr(request, "user", None), auth_requirement, request)
|
|
||||||
|
# Handle callable auth
|
||||||
|
if callable(auth_requirement):
|
||||||
|
try:
|
||||||
|
result = auth_requirement(request)
|
||||||
|
if result:
|
||||||
|
return None # Authorized
|
||||||
|
else:
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Access denied",
|
||||||
|
)
|
||||||
|
except PermissionError as e:
|
||||||
|
# Custom error message from the callable
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message=str(e) or "Access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check authentication (required for all string-based auth)
|
||||||
|
if not getattr(user, "is_authenticated", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.UNAUTHORIZED,
|
||||||
|
message="Authentication required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check staff requirement
|
||||||
|
if auth_requirement == "staff":
|
||||||
|
if not getattr(user, "is_staff", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Staff access required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check superuser requirement
|
||||||
|
elif auth_requirement == "superuser":
|
||||||
|
if not getattr(user, "is_superuser", False):
|
||||||
|
return FunctionError(
|
||||||
|
code=ErrorCode.FORBIDDEN,
|
||||||
|
message="Superuser access required",
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
except _CoreMizanError as e:
|
|
||||||
return FunctionError(code=ErrorCode(e.code.value), message=e.message)
|
|
||||||
|
|
||||||
|
|
||||||
_cache_log = logging.getLogger("mizan.cache")
|
_cache_log = logging.getLogger("mizan.cache")
|
||||||
@@ -162,6 +197,51 @@ def _purge_cache_for_invalidation(
|
|||||||
_cache_log.warning("Cache purge failed", exc_info=True)
|
_cache_log.warning("Cache purge failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
|
"""
|
||||||
|
Determine whether an affects target is a context name or function name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
("context", "user", None) — full context invalidation
|
||||||
|
("function", "user_profile", "user") — function within context
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
|
||||||
|
# Check if it's a context name directly
|
||||||
|
if target_name in groups:
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
# Check if it's a function name within a context
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
if target_name in fn_names:
|
||||||
|
return ("function", target_name, ctx_name)
|
||||||
|
|
||||||
|
# Not a context or context function — treat as context name anyway
|
||||||
|
# (it might be a non-context function or an as-yet-unregistered context)
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context_param_names(context_name: str) -> set[str]:
|
||||||
|
"""
|
||||||
|
Get the set of parameter names used by functions in a context.
|
||||||
|
|
||||||
|
Returns the union of all Input field names across context functions.
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
param_names: set[str] = set()
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
return param_names
|
||||||
|
|
||||||
|
|
||||||
def _resolve_invalidation(
|
def _resolve_invalidation(
|
||||||
view_class: type | None,
|
view_class: type | None,
|
||||||
input_data: dict[str, Any] | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
@@ -180,7 +260,49 @@ def _resolve_invalidation(
|
|||||||
Returns a list suitable for both JSON body and header serialization.
|
Returns a list suitable for both JSON body and header serialization.
|
||||||
Returns None if no invalidation needed.
|
Returns None if no invalidation needed.
|
||||||
"""
|
"""
|
||||||
return _core_inval.resolve_invalidation(view_class, input_data)
|
if view_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
affects = meta.get("affects")
|
||||||
|
if not affects:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for target in affects:
|
||||||
|
if target["type"] == "context":
|
||||||
|
target_name = target["name"]
|
||||||
|
elif target["type"] == "function" and target.get("context"):
|
||||||
|
# Function-level: use the function name as the invalidation key
|
||||||
|
target_name = target["name"]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(target_name)
|
||||||
|
|
||||||
|
# Resolve the context this target belongs to (for param lookup)
|
||||||
|
resolved = _resolve_affects_target(target_name)
|
||||||
|
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||||
|
|
||||||
|
# Tier 1: argument name matching
|
||||||
|
if input_data and ctx_for_params:
|
||||||
|
context_params = _get_context_param_names(ctx_for_params)
|
||||||
|
matched = {
|
||||||
|
k: v for k, v in input_data.items()
|
||||||
|
if k in context_params
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
result.append({"context": target_name, "params": matched})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tier 3: broad fallback
|
||||||
|
result.append(target_name)
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_merges(
|
def _resolve_merges(
|
||||||
@@ -199,12 +321,94 @@ def _resolve_merges(
|
|||||||
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
Mirrors _resolve_invalidation's tier-1 auto-scoping for params.
|
||||||
Entries whose slot can't be uniquely resolved are dropped.
|
Entries whose slot can't be uniquely resolved are dropped.
|
||||||
"""
|
"""
|
||||||
return _core_inval.resolve_merges(view_class, input_data, result_data)
|
if view_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
|
|
||||||
|
meta = getattr(view_class, "_meta", {})
|
||||||
|
targets = meta.get("merge") or []
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ctx_name in targets:
|
||||||
|
if ctx_name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ctx_name)
|
||||||
|
|
||||||
|
slot = _resolve_merge_slot(ctx_name, mutation_output, types_match_for_merge)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result_data}
|
||||||
|
if input_data:
|
||||||
|
context_params = _get_context_param_names(ctx_name)
|
||||||
|
matched = {
|
||||||
|
k: v for k, v in input_data.items()
|
||||||
|
if k in context_params
|
||||||
|
}
|
||||||
|
if matched:
|
||||||
|
entry["params"] = matched
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _format_invalidate_header(invalidate: list[str | dict[str, Any]]) -> str:
|
def _resolve_merge_slot(context_name: str, mutation_output: Any, type_matcher: Any) -> str | None:
|
||||||
"""Format invalidation targets as the X-Mizan-Invalidate header value (shared core)."""
|
"""Find the unique function-name slot in context whose return type matches mutation's output."""
|
||||||
return _core_inval.format_invalidate_header(invalidate)
|
if mutation_output is None:
|
||||||
|
return None
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
matches: list[str] = []
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
fn_output = getattr(fn_cls, "Output", None)
|
||||||
|
if fn_output is not None and type_matcher(fn_output, mutation_output):
|
||||||
|
matches.append(fn_name)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_invalidate_header(
|
||||||
|
invalidate: list[str | dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format invalidation targets as X-Mizan-Invalidate header value.
|
||||||
|
|
||||||
|
Format: comma-separated contexts. Semicolon-separated params per context.
|
||||||
|
Param values are URL-encoded to prevent delimiter collisions.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
["user"] → "user"
|
||||||
|
["user", "notifications"] → "user, notifications"
|
||||||
|
[{"context": "user", "params": {"user_id": 5}}]
|
||||||
|
→ "user;user_id=5"
|
||||||
|
[{"context": "search", "params": {"q": "hello world"}}]
|
||||||
|
→ "search;q=hello%20world"
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for entry in invalidate:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
parts.append(entry)
|
||||||
|
elif isinstance(entry, dict):
|
||||||
|
ctx = entry["context"]
|
||||||
|
params = entry.get("params", {})
|
||||||
|
if params:
|
||||||
|
param_str = ";".join(
|
||||||
|
f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
|
||||||
|
for k, v in sorted(params.items())
|
||||||
|
)
|
||||||
|
parts.append(f"{ctx};{param_str}")
|
||||||
|
else:
|
||||||
|
parts.append(ctx)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def execute_function(
|
def execute_function(
|
||||||
@@ -532,8 +736,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
is_multipart = content_type.startswith("multipart/form-data")
|
is_multipart = content_type.startswith("multipart/form-data")
|
||||||
|
|
||||||
if is_multipart:
|
if is_multipart:
|
||||||
# Multipart carries two shapes: a form submission (Django Form path) or
|
# Multipart form data - used by form submit functions
|
||||||
# an Upload-typed RPC. `fn` selects the function; its kind routes here.
|
|
||||||
fn_name = request.POST.get("fn")
|
fn_name = request.POST.get("fn")
|
||||||
if not fn_name:
|
if not fn_name:
|
||||||
return FunctionError(
|
return FunctionError(
|
||||||
@@ -541,40 +744,12 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
message="Missing 'fn' field",
|
message="Missing 'fn' field",
|
||||||
).to_response()
|
).to_response()
|
||||||
|
|
||||||
fn_class = get_function(fn_name)
|
# Get form data (excluding 'fn')
|
||||||
is_form_fn = bool(getattr(fn_class, "_meta", {}).get("form")) if fn_class else False
|
|
||||||
|
|
||||||
if is_form_fn:
|
|
||||||
# Form submit — POST fields + FILES handed to Django Form validation.
|
|
||||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||||
|
|
||||||
|
# Attach parsed form data and files to request for form functions
|
||||||
request._mizan_form_data = input_data
|
request._mizan_form_data = input_data
|
||||||
request._mizan_form_files = request.FILES
|
request._mizan_form_files = request.FILES
|
||||||
else:
|
|
||||||
# Upload RPC — the `args` JSON part carries the non-file fields; the
|
|
||||||
# file parts bind into the Input's Upload fields (constraints enforced).
|
|
||||||
raw_args = request.POST.get("args")
|
|
||||||
try:
|
|
||||||
input_data = json.loads(raw_args) if raw_args else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return FunctionError(
|
|
||||||
code=ErrorCode.BAD_REQUEST,
|
|
||||||
message="Invalid JSON in 'args' field",
|
|
||||||
).to_response()
|
|
||||||
input_cls = getattr(fn_class, "Input", None)
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
files = {
|
|
||||||
field: [
|
|
||||||
UploadedFile(f.name, f.content_type, f.read())
|
|
||||||
for f in request.FILES.getlist(field)
|
|
||||||
]
|
|
||||||
for field in request.FILES
|
|
||||||
}
|
|
||||||
err = bind_uploads(input_cls, input_data, files)
|
|
||||||
if err is not None:
|
|
||||||
return FunctionError(
|
|
||||||
code=ErrorCode.BAD_REQUEST,
|
|
||||||
message=err,
|
|
||||||
).to_response()
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON body - standard RPC
|
# JSON body - standard RPC
|
||||||
|
|||||||
@@ -1,79 +1,245 @@
|
|||||||
"""
|
"""
|
||||||
JWT tokens — the Django adapter over the shared core (`mizan_core.auth.jwt`).
|
JWT Token Creation and Validation
|
||||||
|
|
||||||
The token logic (mint/decode/refresh, `JWTUser`, `TokenPair`, `TokenPayload`)
|
Uses PyJWT directly - no allauth dependency.
|
||||||
lives in the core; this module binds it to Django settings and keeps the
|
Tokens are tied to Django sessions for immediate revocation on logout.
|
||||||
session-revocation check (`validate_session`), which is Django-session-specific.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from mizan_core.auth import jwt as _core_jwt
|
import jwt
|
||||||
from mizan_core.auth.jwt import JWTConfig, JWTUser, TokenPair, TokenPayload
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
|
|
||||||
from .settings import get_settings
|
from .settings import get_settings
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"TokenPair",
|
class TokenPair(NamedTuple):
|
||||||
"TokenPayload",
|
"""Access and refresh token pair."""
|
||||||
"JWTUser",
|
access_token: str
|
||||||
"create_access_token",
|
refresh_token: str
|
||||||
"create_refresh_token",
|
expires_in: int
|
||||||
"create_token_pair",
|
|
||||||
"decode_token",
|
|
||||||
"validate_session",
|
|
||||||
"refresh_tokens",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> JWTConfig:
|
class TokenPayload(NamedTuple):
|
||||||
s = get_settings()
|
"""Decoded token payload."""
|
||||||
return JWTConfig(
|
user_id: int | str
|
||||||
private_key=s.private_key,
|
session_key: str
|
||||||
public_key=s.public_key,
|
token_type: str
|
||||||
algorithm=s.algorithm,
|
is_staff: bool
|
||||||
access_token_expires_in=s.access_token_expires_in,
|
is_superuser: bool
|
||||||
refresh_token_expires_in=s.refresh_token_expires_in,
|
exp: int
|
||||||
|
iat: int
|
||||||
|
|
||||||
|
|
||||||
|
class JWTUser:
|
||||||
|
"""
|
||||||
|
Minimal user object created from JWT claims.
|
||||||
|
|
||||||
|
Used as request.user for JWT-authenticated requests.
|
||||||
|
No database query required - all data comes from the token.
|
||||||
|
|
||||||
|
If you need the full User object with all fields, query explicitly:
|
||||||
|
user = User.objects.get(pk=request.user.id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, payload: TokenPayload):
|
||||||
|
self.id = int(payload.user_id) if isinstance(payload.user_id, str) else payload.user_id
|
||||||
|
self.pk = self.id
|
||||||
|
self.is_staff = payload.is_staff
|
||||||
|
self.is_superuser = payload.is_superuser
|
||||||
|
self.is_authenticated = True
|
||||||
|
self.is_anonymous = False
|
||||||
|
self.is_active = True # Assumed active if they have a valid token
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"JWTUser(id={self.id})"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"JWTUser(id={self.id}, is_staff={self.is_staff}, is_superuser={self.is_superuser})"
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
user_id: int | str,
|
||||||
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a short-lived access token.
|
||||||
|
|
||||||
|
The token contains:
|
||||||
|
- sub: user ID
|
||||||
|
- sid: session key (for revocation checking)
|
||||||
|
- staff: is_staff flag
|
||||||
|
- super: is_superuser flag
|
||||||
|
- type: "access"
|
||||||
|
- iat: issued at
|
||||||
|
- exp: expiration
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"sid": session_key,
|
||||||
|
"staff": is_staff,
|
||||||
|
"super": is_superuser,
|
||||||
|
"type": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + settings.access_token_expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.private_key,
|
||||||
|
algorithm=settings.algorithm,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
def create_refresh_token(
|
||||||
return _core_jwt.create_access_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a longer-lived refresh token.
|
||||||
|
|
||||||
|
The token contains:
|
||||||
|
- sub: user ID
|
||||||
|
- sid: session key (for revocation checking)
|
||||||
|
- staff: is_staff flag
|
||||||
|
- super: is_superuser flag
|
||||||
|
- type: "refresh"
|
||||||
|
- iat: issued at
|
||||||
|
- exp: expiration
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"sid": session_key,
|
||||||
|
"staff": is_staff,
|
||||||
|
"super": is_superuser,
|
||||||
|
"type": "refresh",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + settings.refresh_token_expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.private_key,
|
||||||
|
algorithm=settings.algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id, session_key, *, is_staff=False, is_superuser=False) -> str:
|
def create_token_pair(
|
||||||
return _core_jwt.create_refresh_token(user_id, session_key, _config(),
|
user_id: int | str,
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
session_key: str,
|
||||||
|
*,
|
||||||
|
is_staff: bool = False,
|
||||||
|
is_superuser: bool = False,
|
||||||
|
) -> TokenPair:
|
||||||
|
"""Create both access and refresh tokens."""
|
||||||
|
settings = get_settings()
|
||||||
|
return TokenPair(
|
||||||
|
access_token=create_access_token(
|
||||||
|
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||||
|
),
|
||||||
|
refresh_token=create_refresh_token(
|
||||||
|
user_id, session_key, is_staff=is_staff, is_superuser=is_superuser
|
||||||
|
),
|
||||||
|
expires_in=settings.access_token_expires_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_token_pair(user_id, session_key, *, is_staff=False, is_superuser=False) -> TokenPair:
|
def decode_token(token: str, expected_type: str = None) -> TokenPayload | None:
|
||||||
return _core_jwt.create_token_pair(user_id, session_key, _config(),
|
"""
|
||||||
is_staff=is_staff, is_superuser=is_superuser)
|
Decode and validate a JWT token.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- Token is invalid or expired
|
||||||
|
- Token type doesn't match expected_type (if specified)
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
def decode_token(token: str, expected_type: str | None = None) -> TokenPayload | None:
|
try:
|
||||||
return _core_jwt.decode_token(token, _config(), expected_type=expected_type)
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.public_key,
|
||||||
|
algorithms=[settings.algorithm],
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate token type if specified
|
||||||
|
if expected_type and payload.get("type") != expected_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TokenPayload(
|
||||||
|
user_id=payload["sub"],
|
||||||
|
session_key=payload["sid"],
|
||||||
|
token_type=payload["type"],
|
||||||
|
is_staff=payload.get("staff", False),
|
||||||
|
is_superuser=payload.get("super", False),
|
||||||
|
exp=payload["exp"],
|
||||||
|
iat=payload["iat"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_session(session_key: str) -> bool:
|
def validate_session(session_key: str) -> bool:
|
||||||
"""Immediate-logout revocation: is this Django session still alive?
|
"""
|
||||||
|
Check if a session is still valid (exists and not expired).
|
||||||
|
|
||||||
Honors `JWT_VALIDATE_SESSION` — when disabled, always True. This is the one
|
This is the key to immediate logout revocation - if the session
|
||||||
Django-session-bound piece; the core's `refresh_tokens` takes it as an
|
is destroyed, tokens tied to it become invalid.
|
||||||
injected `session_validator`.
|
|
||||||
"""
|
"""
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
if not get_settings().validate_session:
|
jwt_settings = get_settings()
|
||||||
|
|
||||||
|
if not jwt_settings.validate_session:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Use the configured session engine
|
||||||
engine = import_module(django_settings.SESSION_ENGINE)
|
engine = import_module(django_settings.SESSION_ENGINE)
|
||||||
session = engine.SessionStore(session_key=session_key)
|
SessionStore = engine.SessionStore
|
||||||
|
|
||||||
|
# Try to load the session
|
||||||
|
session = SessionStore(session_key=session_key)
|
||||||
|
|
||||||
|
# Check if session exists and is not empty
|
||||||
|
# exists() is more reliable than checking load() result
|
||||||
return session.exists(session_key)
|
return session.exists(session_key)
|
||||||
|
|
||||||
|
|
||||||
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
def refresh_tokens(refresh_token: str) -> TokenPair | None:
|
||||||
return _core_jwt.refresh_tokens(refresh_token, _config(), session_validator=validate_session)
|
"""
|
||||||
|
Use a refresh token to obtain new tokens.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- Refresh token is invalid or expired
|
||||||
|
- Associated session no longer exists
|
||||||
|
"""
|
||||||
|
payload = decode_token(refresh_token, expected_type="refresh")
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate the session still exists
|
||||||
|
if not validate_session(payload.session_key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Issue new token pair with same claims
|
||||||
|
return create_token_pair(
|
||||||
|
payload.user_id,
|
||||||
|
payload.session_key,
|
||||||
|
is_staff=payload.is_staff,
|
||||||
|
is_superuser=payload.is_superuser,
|
||||||
|
)
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ class HTTPAuthTests(TestCase):
|
|||||||
|
|
||||||
def test_jwt_expired_with_session(self):
|
def test_jwt_expired_with_session(self):
|
||||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||||
# Create token with past expiration by mocking time (minting lives in the core now)
|
# Create token with past expiration by mocking time
|
||||||
with patch("mizan_core.auth.jwt.time.time", return_value=0):
|
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||||
tokens = create_token_pair(
|
tokens = create_token_pair(
|
||||||
self.user.pk,
|
self.user.pk,
|
||||||
self.session_key,
|
self.session_key,
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -8,7 +8,6 @@ dependencies = [
|
|||||||
"mizan-core",
|
"mizan-core",
|
||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"python-multipart>=0.0.9",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -35,18 +35,8 @@ from .executor import (
|
|||||||
execute_function,
|
execute_function,
|
||||||
)
|
)
|
||||||
from .router import router, mizan_exception_handler, mizan_validation_handler
|
from .router import router, mizan_exception_handler, mizan_validation_handler
|
||||||
from .auth import MizanAuthMiddleware, mizan_auth
|
|
||||||
from .config import MizanConfig, from_env
|
|
||||||
from mizan_core.upload import File, Upload, UploadedFile
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Upload",
|
|
||||||
"File",
|
|
||||||
"UploadedFile",
|
|
||||||
"mizan_auth",
|
|
||||||
"MizanAuthMiddleware",
|
|
||||||
"MizanConfig",
|
|
||||||
"from_env",
|
|
||||||
"router",
|
"router",
|
||||||
"mizan_exception_handler",
|
"mizan_exception_handler",
|
||||||
"mizan_validation_handler",
|
"mizan_validation_handler",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -1,69 +1,263 @@
|
|||||||
"""
|
"""
|
||||||
Dispatch — a thin shim over the shared core (`mizan_core.dispatch`).
|
RPC dispatch — looks up registered functions, validates input against the
|
||||||
|
function's Pydantic Input model, executes, and returns the serialized result.
|
||||||
|
|
||||||
The protocol machinery (auth, validation, execution, invalidation, merge, cache)
|
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
|
||||||
lives in `mizan_core`; this module re-exports the canonical error taxonomy and
|
responses by registering `mizan_exception_handler` on the FastAPI app, or
|
||||||
keeps backward-compatible helpers. The router drives `dispatch_call` /
|
let them propagate to your own handler.
|
||||||
`dispatch_context` directly to get invalidation + origin cache.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
from fastapi.encoders import jsonable_encoder
|
||||||
from mizan_core.errors import (
|
from pydantic import BaseModel, ValidationError
|
||||||
BadRequest,
|
|
||||||
ErrorCode,
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
Forbidden,
|
from mizan_core.type_utils import types_match_for_merge
|
||||||
InternalError,
|
|
||||||
MizanError,
|
|
||||||
NotFound,
|
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
||||||
NotImplementedYet,
|
|
||||||
Unauthorized,
|
|
||||||
ValidationFailed,
|
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}},
|
||||||
)
|
)
|
||||||
from mizan_core.invalidation import resolve_invalidation, resolve_merges
|
return input_cls()
|
||||||
|
|
||||||
__all__ = [
|
if not isinstance(input_data, dict):
|
||||||
"ErrorCode",
|
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
||||||
"MizanError",
|
|
||||||
"NotFound",
|
try:
|
||||||
"BadRequest",
|
return input_cls(**input_data)
|
||||||
"ValidationFailed",
|
except ValidationError as e:
|
||||||
"Unauthorized",
|
raise ValidationFailed(
|
||||||
"Forbidden",
|
"Input validation failed",
|
||||||
"NotImplementedYet",
|
details={"errors": e.errors()},
|
||||||
"InternalError",
|
) from e
|
||||||
"compute_invalidation",
|
|
||||||
"compute_merges",
|
|
||||||
"execute_function",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_NO_CACHE = CacheOrchestrator(None, None)
|
# ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_function(fn_name: str) -> Any:
|
||||||
|
view_class = get_function(fn_name)
|
||||||
|
if view_class is None:
|
||||||
|
raise NotFound("Function not found")
|
||||||
|
if getattr(view_class, "_meta", {}).get("private"):
|
||||||
|
raise Forbidden("Function is not client-callable")
|
||||||
|
return view_class
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(result: Any) -> Any:
|
||||||
|
# jsonable_encoder walks BaseModel / list / dict recursively, so list[BaseModel]
|
||||||
|
# (and nested shapes) come out wire-ready without a per-shape branch here.
|
||||||
|
return jsonable_encoder(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_function(
|
||||||
|
request: Any,
|
||||||
|
fn_name: str,
|
||||||
|
input_data: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Dispatch a registered function. Returns the serialized result, or raises MizanError.
|
||||||
|
|
||||||
|
Awaits `view.acall` — async handlers run on the loop, sync handlers run
|
||||||
|
in the default threadpool, both via the same entrypoint.
|
||||||
|
"""
|
||||||
|
view_class = _resolve_function(fn_name)
|
||||||
|
_enforce_auth(request, view_class._meta.get("auth"))
|
||||||
|
|
||||||
|
view = view_class(request)
|
||||||
|
validated = _validate_input(view.Input, input_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await view.acall(validated)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
raise NotImplementedYet(str(e) or "Not implemented") from e
|
||||||
|
except MizanError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise InternalError(str(e)) from e
|
||||||
|
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Invalidation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
def compute_invalidation(view_class: Any, input_data: dict[str, Any] | None) -> list[Any]:
|
||||||
"""`@client(affects=...)` → invalidation list (empty when none). Shared core."""
|
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
|
||||||
return resolve_invalidation(view_class, input_data) or []
|
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
||||||
|
return [_invalidation_target(target, input_data or {}) for target in affects]
|
||||||
|
|
||||||
|
|
||||||
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
def compute_merges(view_class: Any, input_data: dict[str, Any] | None, result: Any) -> list[dict[str, Any]]:
|
||||||
"""`@client(merge=...)` → merge list (empty when none). Shared core."""
|
"""Build the `merge` list from @client(merge=...) metadata.
|
||||||
return resolve_merges(view_class, input_data, result) or []
|
|
||||||
|
|
||||||
|
Each entry is `{context, slot, value, params?}` where `slot` names the
|
||||||
async def execute_function(request: Any, fn_name: str, input_data: dict[str, Any] | None = None) -> Any:
|
function inside the context bundle the value lands in. The slot is
|
||||||
"""Dispatch a function and return its serialized result (auth enforced via core).
|
resolved server-side via `types_match_for_merge` so the kernel does
|
||||||
|
no shape inference — the server has the schema, type-checked routing
|
||||||
Backward-compat entry point; the router uses `dispatch_call` directly to also
|
lives here. Entries whose slot can't be uniquely resolved are dropped
|
||||||
capture invalidation/merge and run the origin cache.
|
with a warning; the consumer falls back to refetch via `affects`.
|
||||||
"""
|
"""
|
||||||
identity = getattr(getattr(request, "state", None), "user", None)
|
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
||||||
res = await dispatch_call(
|
if not targets:
|
||||||
DispatchRequest(identity=identity, args=input_data, native_request=request),
|
return []
|
||||||
fn_name,
|
mutation_output = getattr(view_class, "Output", None)
|
||||||
_NO_CACHE,
|
out: list[dict[str, Any]] = []
|
||||||
)
|
for ctx_name in targets:
|
||||||
return res.data
|
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
||||||
|
scoped = _scoped_params(ctx_name, input_data or {})
|
||||||
|
if scoped:
|
||||||
|
entry["params"] = scoped
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
||||||
|
"""Find the unique function-name slot whose return type matches the mutation's output.
|
||||||
|
|
||||||
|
Returns None on no match or ambiguous match (multiple candidates).
|
||||||
|
"""
|
||||||
|
if mutation_output is None:
|
||||||
|
return None
|
||||||
|
matches: list[str] = []
|
||||||
|
for fn_name in get_context_groups().get(context_name, []):
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
fn_output = getattr(fn_cls, "Output", None)
|
||||||
|
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
||||||
|
matches.append(fn_name)
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Match input args against the context's declared Input field names."""
|
||||||
|
fn_names = get_context_groups().get(context_name, [])
|
||||||
|
declared: set[str] = set()
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
declared.update(input_cls.model_fields.keys())
|
||||||
|
return {k: v for k, v in input_data.items() if k in declared}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
|
||||||
|
match target.get("type"):
|
||||||
|
case "context":
|
||||||
|
name = target["name"]
|
||||||
|
scoped = _scoped_params(name, input_data)
|
||||||
|
return {"context": name, "params": scoped} if scoped else name
|
||||||
|
case "function":
|
||||||
|
return {"function": target["name"]}
|
||||||
|
case _:
|
||||||
|
return target
|
||||||
|
|||||||
@@ -14,22 +14,23 @@ FastAPI router exposing Mizan's HTTP endpoints:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field
|
||||||
from starlette.datastructures import UploadFile
|
|
||||||
|
|
||||||
from mizan_core.auth import INVALID, authenticate
|
from mizan_core.registry import get_context_groups, get_function
|
||||||
from mizan_core.dispatch import DispatchRequest, dispatch_call, dispatch_context
|
|
||||||
from mizan_core.errors import BadRequest, ErrorCode, MizanError, Unauthorized
|
|
||||||
from mizan_core.registry import get_function
|
|
||||||
from mizan_core.upload import UploadedFile, bind_uploads
|
|
||||||
|
|
||||||
from .config import MizanConfig, get_config
|
from .executor import (
|
||||||
|
ErrorCode,
|
||||||
|
MizanError,
|
||||||
|
NotFound,
|
||||||
|
compute_invalidation,
|
||||||
|
compute_merges,
|
||||||
|
execute_function,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -58,95 +59,29 @@ class CallBody(BaseModel):
|
|||||||
args: dict[str, Any] = Field(default_factory=dict)
|
args: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
async def _parse_call(request: Request) -> tuple[str, dict[str, Any]]:
|
|
||||||
"""Read a call request, JSON or multipart. Returns `(fn, args)`.
|
|
||||||
|
|
||||||
Multipart carries the non-file fields in a JSON `args` part and each file as
|
|
||||||
its own part; the file parts bind into the Input's Upload fields with the
|
|
||||||
declarative `File(...)` constraints enforced.
|
|
||||||
"""
|
|
||||||
content_type = request.headers.get("content-type", "")
|
|
||||||
if content_type.startswith("multipart/form-data"):
|
|
||||||
form = await request.form()
|
|
||||||
fn = form.get("fn")
|
|
||||||
if not isinstance(fn, str) or not fn:
|
|
||||||
raise BadRequest("Missing 'fn' field")
|
|
||||||
raw_args = form.get("args")
|
|
||||||
try:
|
|
||||||
args: dict[str, Any] = json.loads(raw_args) if raw_args else {}
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise BadRequest("Invalid JSON in 'args' field")
|
|
||||||
|
|
||||||
fn_class = get_function(fn)
|
|
||||||
input_cls = getattr(fn_class, "Input", None) if fn_class else None
|
|
||||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
|
||||||
files: dict[str, list[UploadedFile]] = {}
|
|
||||||
for key in set(form.keys()):
|
|
||||||
wrapped = [
|
|
||||||
UploadedFile(p.filename, p.content_type, await p.read())
|
|
||||||
for p in form.getlist(key)
|
|
||||||
if isinstance(p, UploadFile)
|
|
||||||
]
|
|
||||||
if wrapped:
|
|
||||||
files[key] = wrapped
|
|
||||||
err = bind_uploads(input_cls, args, files)
|
|
||||||
if err is not None:
|
|
||||||
raise BadRequest(err)
|
|
||||||
return fn, args
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = CallBody(**(await request.json()))
|
|
||||||
except (ValueError, ValidationError):
|
|
||||||
raise BadRequest("Invalid request body")
|
|
||||||
return body.fn, body.args
|
|
||||||
|
|
||||||
|
|
||||||
def _identity(request: Request, cfg: MizanConfig):
|
|
||||||
"""Identity for dispatch: a host-set `request.state.user`, else a token decode.
|
|
||||||
|
|
||||||
A present-but-invalid token rejects (401); no token → None (anonymous).
|
|
||||||
"""
|
|
||||||
existing = getattr(getattr(request, "state", None), "user", None)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
ident = authenticate(request.headers, cfg.auth)
|
|
||||||
if ident is INVALID:
|
|
||||||
raise Unauthorized("Invalid or expired token")
|
|
||||||
return ident
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/call/")
|
@router.post("/call/")
|
||||||
async def function_call(request: Request) -> JSONResponse:
|
async def function_call(body: CallBody, request: Request) -> JSONResponse:
|
||||||
"""RPC dispatch — JSON or multipart → `{"result", "invalidate", "merge"?}` with
|
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...], "merge"?: [...]}`."""
|
||||||
the `X-Mizan-Invalidate` header alongside the body."""
|
fn_class = get_function(body.fn)
|
||||||
cfg = get_config(request)
|
result = await execute_function(request, body.fn, body.args)
|
||||||
fn, args = await _parse_call(request)
|
invalidate = compute_invalidation(fn_class, body.args)
|
||||||
res = await dispatch_call(
|
merges = compute_merges(fn_class, body.args, result)
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=args, native_request=request),
|
payload: dict[str, Any] = {"result": result, "invalidate": invalidate}
|
||||||
fn, cfg.cache,
|
if merges:
|
||||||
)
|
payload["merge"] = merges
|
||||||
payload: dict[str, Any] = {"result": res.data, "invalidate": res.invalidate or []}
|
return _no_store(payload)
|
||||||
if res.merge:
|
|
||||||
payload["merge"] = res.merge
|
|
||||||
headers = {"Cache-Control": "no-store"}
|
|
||||||
if res.invalidate_header:
|
|
||||||
headers["X-Mizan-Invalidate"] = res.invalidate_header
|
|
||||||
return JSONResponse(payload, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ctx/{context_name}/")
|
@router.get("/ctx/{context_name}/")
|
||||||
async def context_fetch(context_name: str, request: Request) -> Response:
|
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
|
||||||
"""Bundled context fetch — origin-cached. `{function_name: result, ...}`."""
|
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
|
||||||
cfg = get_config(request)
|
fn_names = get_context_groups().get(context_name)
|
||||||
res = await dispatch_context(
|
if not fn_names:
|
||||||
DispatchRequest(identity=_identity(request, cfg), args=dict(request.query_params),
|
raise NotFound(f"Context '{context_name}' not found")
|
||||||
native_request=request),
|
|
||||||
context_name, cfg.cache,
|
params = dict(request.query_params)
|
||||||
)
|
bundled = {fn: await execute_function(request, fn, params) for fn in fn_names}
|
||||||
headers = {"Cache-Control": "no-store"}
|
return _no_store(bundled)
|
||||||
if res.cache_status:
|
|
||||||
headers["X-Mizan-Cache"] = res.cache_status
|
|
||||||
return Response(content=res.body_bytes, media_type="application/json", headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Exception handler ──────────────────────────────────────────────────────
|
# ─── Exception handler ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types'
|
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||||
import { register } from './registry'
|
import { register } from './registry'
|
||||||
|
|
||||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||||
@@ -21,19 +21,6 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize the public auth option into the stored requirement.
|
|
||||||
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
|
|
||||||
* 'staff'/'superuser' pass through, anything else throws at decoration time.
|
|
||||||
*/
|
|
||||||
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
|
|
||||||
if (auth === undefined) return undefined
|
|
||||||
if (auth === true) return 'required'
|
|
||||||
if (typeof auth === 'function') return auth
|
|
||||||
if (auth === 'staff' || auth === 'superuser') return auth
|
|
||||||
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAffects(
|
function normalizeAffects(
|
||||||
affects: ClientOptions['affects'],
|
affects: ClientOptions['affects'],
|
||||||
): RegistryEntry['affects'] | undefined {
|
): RegistryEntry['affects'] | undefined {
|
||||||
@@ -110,7 +97,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
viewPath: isView,
|
viewPath: isView,
|
||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: normalizeAuth(options.auth),
|
auth: options.auth,
|
||||||
rev: options.rev,
|
rev: options.rev,
|
||||||
cache: options.cache,
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
@@ -142,7 +129,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
|||||||
viewPath: false,
|
viewPath: false,
|
||||||
route: options.route,
|
route: options.route,
|
||||||
methods: options.methods,
|
methods: options.methods,
|
||||||
auth: normalizeAuth(options.auth),
|
auth: options.auth,
|
||||||
rev: options.rev,
|
rev: options.rev,
|
||||||
cache: options.cache,
|
cache: options.cache,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
import { getFunction, getContextGroups } from './registry'
|
import { getFunction, getContextGroups } from './registry'
|
||||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||||
import { ANONYMOUS, type Identity } from './identity'
|
|
||||||
import type { AuthRequirement } from './types'
|
|
||||||
|
|
||||||
let _cacheSecret: string | null = null
|
let _cacheSecret: string | null = null
|
||||||
|
|
||||||
@@ -24,54 +22,6 @@ export interface MizanResponse {
|
|||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthDenial {
|
|
||||||
status: 401 | 403
|
|
||||||
code: 'UNAUTHORIZED' | 'FORBIDDEN'
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether `identity` satisfies the stored `auth` requirement.
|
|
||||||
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
|
|
||||||
* on failure, or null when access is allowed.
|
|
||||||
*/
|
|
||||||
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
|
|
||||||
if (auth === undefined) return null
|
|
||||||
|
|
||||||
// Callable runs first — before the authentication gate.
|
|
||||||
if (typeof auth === 'function') {
|
|
||||||
try {
|
|
||||||
return auth(identity)
|
|
||||||
? null
|
|
||||||
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!identity.isAuthenticated) {
|
|
||||||
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth === 'staff' && !identity.isStaff) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth === 'superuser' && !identity.isSuperuser) {
|
|
||||||
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function authDenialResponse(denial: AuthDenial): MizanResponse {
|
|
||||||
return {
|
|
||||||
status: denial.status,
|
|
||||||
body: { error: true, code: denial.code, message: denial.message },
|
|
||||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle GET /api/mizan/ctx/:contextName/
|
* Handle GET /api/mizan/ctx/:contextName/
|
||||||
*
|
*
|
||||||
@@ -80,7 +30,6 @@ function authDenialResponse(denial: AuthDenial): MizanResponse {
|
|||||||
export async function handleContextFetch(
|
export async function handleContextFetch(
|
||||||
contextName: string,
|
contextName: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const groups = getContextGroups()
|
const groups = getContextGroups()
|
||||||
const fnNames = groups[contextName]
|
const fnNames = groups[contextName]
|
||||||
@@ -93,15 +42,6 @@ export async function handleContextFetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
|
|
||||||
// leak to an unauthorized caller. Any denial short-circuits, uncached.
|
|
||||||
for (const fnName of fnNames) {
|
|
||||||
const entry = getFunction(fnName)
|
|
||||||
if (!entry) continue
|
|
||||||
const denial = checkAuth(entry.auth, identity)
|
|
||||||
if (denial) return authDenialResponse(denial)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||||
let effectiveRev = 0
|
let effectiveRev = 0
|
||||||
for (const fnName of fnNames) {
|
for (const fnName of fnNames) {
|
||||||
@@ -193,7 +133,6 @@ export async function handleContextFetch(
|
|||||||
export async function handleMutationCall(
|
export async function handleMutationCall(
|
||||||
fnName: string,
|
fnName: string,
|
||||||
args: Record<string, any>,
|
args: Record<string, any>,
|
||||||
identity: Identity = ANONYMOUS,
|
|
||||||
): Promise<MizanResponse> {
|
): Promise<MizanResponse> {
|
||||||
const entry = getFunction(fnName)
|
const entry = getFunction(fnName)
|
||||||
|
|
||||||
@@ -214,10 +153,6 @@ export async function handleMutationCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth enforcement — after private rejection, before execution.
|
|
||||||
const denial = checkAuth(entry.auth, identity)
|
|
||||||
if (denial) return authDenialResponse(denial)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const argValues = entry.params.map(p => args[p.name])
|
const argValues = entry.params.map(p => args[p.name])
|
||||||
const result = await entry.fn(...argValues)
|
const result = await entry.fn(...argValues)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
export { ReactContext } from './types'
|
export { ReactContext } from './types'
|
||||||
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types'
|
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
|
||||||
|
|
||||||
export { ANONYMOUS } from './identity'
|
|
||||||
export type { Identity, AuthPredicate } from './identity'
|
|
||||||
|
|
||||||
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
|
|
||||||
export type { MwtPayload } from './token'
|
|
||||||
|
|
||||||
export { client } from './decorator'
|
export { client } from './decorator'
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* MWT / JWT decode — HS256 verification, cross-language parity with
|
|
||||||
* cores/mizan-python/src/mizan_core/mwt.py.
|
|
||||||
*
|
|
||||||
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
|
|
||||||
* aud, malformed). Never throws.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createHmac, timingSafeEqual } from 'crypto'
|
|
||||||
import type { Identity } from './identity'
|
|
||||||
|
|
||||||
export interface MwtPayload {
|
|
||||||
sub: string
|
|
||||||
staff: boolean
|
|
||||||
super: boolean
|
|
||||||
pkey: string
|
|
||||||
kid: string
|
|
||||||
aud: string
|
|
||||||
iat: number
|
|
||||||
exp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64urlDecode(input: string): Buffer | null {
|
|
||||||
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null
|
|
||||||
return Buffer.from(input, 'base64url')
|
|
||||||
}
|
|
||||||
|
|
||||||
function constantTimeEqual(a: Buffer, b: Buffer): boolean {
|
|
||||||
if (a.length !== b.length) return false
|
|
||||||
return timingSafeEqual(a, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode and validate an MWT (HS256 JWT with Mizan claims).
|
|
||||||
* Returns MwtPayload on success, null on any failure.
|
|
||||||
*/
|
|
||||||
export function decodeMwt(
|
|
||||||
token: string,
|
|
||||||
secret: string,
|
|
||||||
audience: string = 'mizan',
|
|
||||||
): MwtPayload | null {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.')
|
|
||||||
if (parts.length !== 3) return null
|
|
||||||
const [headerB64, payloadB64, signatureB64] = parts
|
|
||||||
|
|
||||||
const headerBytes = base64urlDecode(headerB64)
|
|
||||||
const payloadBytes = base64urlDecode(payloadB64)
|
|
||||||
const signatureBytes = base64urlDecode(signatureB64)
|
|
||||||
if (!headerBytes || !payloadBytes || !signatureBytes) return null
|
|
||||||
|
|
||||||
const header = JSON.parse(headerBytes.toString('utf-8'))
|
|
||||||
if (header.alg !== 'HS256') return null
|
|
||||||
|
|
||||||
// Recompute HMAC over `${headerB64}.${payloadB64}`
|
|
||||||
const expected = createHmac('sha256', secret)
|
|
||||||
.update(`${headerB64}.${payloadB64}`)
|
|
||||||
.digest()
|
|
||||||
if (!constantTimeEqual(expected, signatureBytes)) return null
|
|
||||||
|
|
||||||
const data = JSON.parse(payloadBytes.toString('utf-8'))
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
if (typeof data.exp !== 'number' || data.exp <= now) return null
|
|
||||||
if (data.nbf !== undefined && typeof data.nbf === 'number' && data.nbf > now) return null
|
|
||||||
if (data.aud !== audience) return null
|
|
||||||
|
|
||||||
const kid = typeof header.kid === 'string' ? header.kid : 'v1'
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: String(data.sub),
|
|
||||||
staff: Boolean(data.staff),
|
|
||||||
super: Boolean(data.super),
|
|
||||||
pkey: typeof data.pkey === 'string' ? data.pkey : '',
|
|
||||||
kid,
|
|
||||||
aud: audience,
|
|
||||||
iat: data.iat,
|
|
||||||
exp: data.exp,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a Bearer JWT from an Authorization header value.
|
|
||||||
* Strips the "Bearer " prefix, then validates as an MWT.
|
|
||||||
*/
|
|
||||||
export function decodeJwtBearer(
|
|
||||||
authHeader: string,
|
|
||||||
secret: string,
|
|
||||||
audience: string = 'mizan',
|
|
||||||
): MwtPayload | null {
|
|
||||||
if (!authHeader) return null
|
|
||||||
const prefix = 'Bearer '
|
|
||||||
const token = authHeader.startsWith(prefix)
|
|
||||||
? authHeader.slice(prefix.length)
|
|
||||||
: authHeader
|
|
||||||
return decodeMwt(token, secret, audience)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build an Identity from a decoded MWT payload. */
|
|
||||||
export function identityFromMwt(payload: MwtPayload): Identity {
|
|
||||||
return {
|
|
||||||
isAuthenticated: true,
|
|
||||||
isStaff: payload.staff,
|
|
||||||
isSuperuser: payload.super,
|
|
||||||
id: Number(payload.sub),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
* Mizan TypeScript Adapter — Shared Types
|
* Mizan TypeScript Adapter — Shared Types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AuthPredicate } from './identity'
|
|
||||||
|
|
||||||
export class ReactContext {
|
export class ReactContext {
|
||||||
constructor(public readonly name: string) {
|
constructor(public readonly name: string) {
|
||||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||||
@@ -12,19 +10,13 @@ export class ReactContext {
|
|||||||
|
|
||||||
export type AffectsTarget = ReactContext | string
|
export type AffectsTarget = ReactContext | string
|
||||||
|
|
||||||
/** Public auth option on the decorator. `true` normalizes to `'required'` when stored. */
|
|
||||||
export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
|
|
||||||
|
|
||||||
/** Normalized auth requirement as stored on the registry entry. */
|
|
||||||
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
|
|
||||||
|
|
||||||
export interface ClientOptions {
|
export interface ClientOptions {
|
||||||
context?: ReactContext | string
|
context?: ReactContext | string
|
||||||
affects?: AffectsTarget | AffectsTarget[]
|
affects?: AffectsTarget | AffectsTarget[]
|
||||||
private?: boolean
|
private?: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthOption
|
auth?: boolean
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
}
|
}
|
||||||
@@ -45,7 +37,7 @@ export interface RegistryEntry {
|
|||||||
viewPath: boolean
|
viewPath: boolean
|
||||||
route?: string
|
route?: string
|
||||||
methods?: string[]
|
methods?: string[]
|
||||||
auth?: AuthRequirement
|
auth?: boolean
|
||||||
rev?: number
|
rev?: number
|
||||||
cache?: number | false
|
cache?: number | false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test'
|
|
||||||
import { createHmac } from 'crypto'
|
|
||||||
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
|
|
||||||
|
|
||||||
function b64url(buf: Buffer | string): string {
|
|
||||||
return Buffer.from(buf).toString('base64url')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mint an HS256 MWT with node crypto, mirroring Python create_mwt. */
|
|
||||||
function mint(payload: Record<string, any>, secret: string, kid = 'v1'): string {
|
|
||||||
const header = b64url(JSON.stringify({ alg: 'HS256', kid, typ: 'JWT' }))
|
|
||||||
const body = b64url(JSON.stringify(payload))
|
|
||||||
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
|
|
||||||
return `${header}.${body}.${sig}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET = 'round-trip-secret'
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
|
||||||
|
|
||||||
function basePayload(overrides: Record<string, any> = {}) {
|
|
||||||
return {
|
|
||||||
sub: '7',
|
|
||||||
staff: true,
|
|
||||||
super: false,
|
|
||||||
pkey: 'abc123',
|
|
||||||
aud: 'mizan',
|
|
||||||
iat: now,
|
|
||||||
nbf: now,
|
|
||||||
exp: now + 300,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MWT round-trip', () => {
|
|
||||||
test('valid token decodes', () => {
|
|
||||||
const token = mint(basePayload(), SECRET)
|
|
||||||
const p = decodeMwt(token, SECRET)
|
|
||||||
expect(p).not.toBeNull()
|
|
||||||
expect(p!.sub).toBe('7')
|
|
||||||
expect(p!.staff).toBe(true)
|
|
||||||
expect(p!.super).toBe(false)
|
|
||||||
expect(p!.pkey).toBe('abc123')
|
|
||||||
expect(p!.kid).toBe('v1')
|
|
||||||
expect(p!.aud).toBe('mizan')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('identityFromMwt maps claims', () => {
|
|
||||||
const token = mint(basePayload({ sub: '99', staff: false, super: true }), SECRET)
|
|
||||||
const p = decodeMwt(token, SECRET)!
|
|
||||||
expect(identityFromMwt(p)).toEqual({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isStaff: false,
|
|
||||||
isSuperuser: true,
|
|
||||||
id: 99,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('decodeJwtBearer strips Bearer prefix', () => {
|
|
||||||
const token = mint(basePayload(), SECRET)
|
|
||||||
const p = decodeJwtBearer(`Bearer ${token}`, SECRET)
|
|
||||||
expect(p).not.toBeNull()
|
|
||||||
expect(p!.sub).toBe('7')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on tampered signature', () => {
|
|
||||||
const token = mint(basePayload(), SECRET)
|
|
||||||
const tampered = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA')
|
|
||||||
expect(decodeMwt(tampered, SECRET)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on wrong secret', () => {
|
|
||||||
const token = mint(basePayload(), SECRET)
|
|
||||||
expect(decodeMwt(token, 'other-secret')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on expired exp', () => {
|
|
||||||
const token = mint(basePayload({ exp: now - 10 }), SECRET)
|
|
||||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on future nbf', () => {
|
|
||||||
const token = mint(basePayload({ nbf: now + 1000 }), SECRET)
|
|
||||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on wrong aud', () => {
|
|
||||||
const token = mint(basePayload({ aud: 'other' }), SECRET)
|
|
||||||
expect(decodeMwt(token, SECRET)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('null on malformed token', () => {
|
|
||||||
expect(decodeMwt('not.a.jwt', SECRET)).toBeNull()
|
|
||||||
expect(decodeMwt('onlyonepart', SECRET)).toBeNull()
|
|
||||||
expect(decodeMwt('', SECRET)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('MWT cross-language pin (Python create_mwt)', () => {
|
|
||||||
const TOKEN = 'eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJzdWIiOiI0MiIsInN0YWZmIjp0cnVlLCJzdXBlciI6ZmFsc2UsInBrZXkiOiIwZTk5OGE5ZmYxNjkwNDYzN2EwM2QyZWEwZmJkYmY5NzQyOTdhOWQxYTVkMjViOGQ0Mjk0ZmE4ODIxMTVlNDU3IiwiYXVkIjoibWl6YW4iLCJpYXQiOjE3MDAwMDAwMDAsIm5iZiI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwfQ._V92JXiLSLXoyuSwbNvvJjwzgmczmC7dvX34kVSLIa8'
|
|
||||||
const PIN_SECRET = 'pin-test-secret-mwt'
|
|
||||||
|
|
||||||
test('decodes the Python-minted token', () => {
|
|
||||||
const p = decodeMwt(TOKEN, PIN_SECRET)
|
|
||||||
expect(p).not.toBeNull()
|
|
||||||
expect(p!.sub).toBe('42')
|
|
||||||
expect(p!.staff).toBe(true)
|
|
||||||
expect(p!.super).toBe(false)
|
|
||||||
expect(p!.pkey).toBe('0e998a9ff16904637a03d2ea0fbdbf974297a9d1a5d25b8d4294fa882115e457')
|
|
||||||
expect(p!.kid).toBe('v1')
|
|
||||||
expect(p!.aud).toBe('mizan')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('identity from Python-minted token', () => {
|
|
||||||
const p = decodeMwt(TOKEN, PIN_SECRET)!
|
|
||||||
expect(identityFromMwt(p)).toEqual({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isStaff: true,
|
|
||||||
isSuperuser: false,
|
|
||||||
id: 42,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -6,7 +6,6 @@ description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-ag
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
"pydantic>=2.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from mizan_core.upload import File, Upload, UploadedFile, validate_upload
|
|
||||||
|
|
||||||
__all__ = ["Upload", "File", "UploadedFile", "validate_upload"]
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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}")
|
|
||||||
@@ -487,10 +487,8 @@ def _create_server_function(
|
|||||||
# Use function name directly
|
# Use function name directly
|
||||||
name = fn.__name__
|
name = fn.__name__
|
||||||
|
|
||||||
# Extract type hints and signature. include_extras keeps `Annotated[...]`
|
# Extract type hints and signature
|
||||||
# metadata (e.g. the `File(...)` marker on an Upload field) intact so it
|
hints = get_type_hints(fn)
|
||||||
# survives into the generated Input model.
|
|
||||||
hints = get_type_hints(fn, include_extras=True)
|
|
||||||
sig = inspect.signature(fn)
|
sig = inspect.signature(fn)
|
||||||
params = list(sig.parameters.items())
|
params = list(sig.parameters.items())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -17,7 +17,6 @@ KDL grammar — locked contract:
|
|||||||
| list { <type-child> }
|
| list { <type-child> }
|
||||||
| optional { <type-child> }
|
| optional { <type-child> }
|
||||||
| enum "<v1>" "<v2>" ...
|
| enum "<v1>" "<v2>" ...
|
||||||
| upload max-size=<int>? { content-type "<mime>" ... }
|
|
||||||
}
|
}
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,6 @@ from pydantic_core import PydanticUndefined
|
|||||||
|
|
||||||
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
from mizan_core.registry import get_all_functions, get_context_groups, get_function
|
||||||
from mizan_core.type_utils import extract_list_element, extract_optional
|
from mizan_core.type_utils import extract_list_element, extract_optional
|
||||||
from mizan_core.upload import File, classify_upload
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["build_ir"]
|
__all__ = ["build_ir"]
|
||||||
@@ -246,34 +244,6 @@ def _emit_alias_type(block: _Block, annotation: Any, named_types: dict[str, Any]
|
|||||||
_emit_type_child(alias_block, annotation, named_types)
|
_emit_type_child(alias_block, annotation, named_types)
|
||||||
|
|
||||||
|
|
||||||
def _emit_upload_node(block: _Block, spec: File | None) -> None:
|
|
||||||
"""Emit the `upload` type-child, with optional `max-size` + `content-type`s."""
|
|
||||||
props: dict[str, str] = {}
|
|
||||||
if spec is not None and spec.max_size is not None:
|
|
||||||
props["max-size"] = repr(spec.max_size)
|
|
||||||
if spec is not None and spec.content_types:
|
|
||||||
with block.node("upload", **props) as up:
|
|
||||||
for ct in spec.content_types:
|
|
||||||
up.leaf("content-type", _kdl_string(ct))
|
|
||||||
else:
|
|
||||||
block.leaf("upload", **props)
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_upload_child(block: _Block, is_list: bool, is_optional: bool, spec: File | None) -> None:
|
|
||||||
"""Emit an Upload type-child, wrapped in `optional`/`list` to match the field."""
|
|
||||||
if is_optional and is_list:
|
|
||||||
with block.node("optional") as opt, opt.node("list") as lst:
|
|
||||||
_emit_upload_node(lst, spec)
|
|
||||||
elif is_optional:
|
|
||||||
with block.node("optional") as opt:
|
|
||||||
_emit_upload_node(opt, spec)
|
|
||||||
elif is_list:
|
|
||||||
with block.node("list") as lst:
|
|
||||||
_emit_upload_node(lst, spec)
|
|
||||||
else:
|
|
||||||
_emit_upload_node(block, spec)
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[str, Any]) -> None:
|
||||||
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
"""Emit a `struct { field ... }` block for a Pydantic model."""
|
||||||
with block.node("struct") as struct_block:
|
with block.node("struct") as struct_block:
|
||||||
@@ -289,10 +259,6 @@ def _emit_struct_type(block: _Block, model: type[BaseModel], named_types: dict[s
|
|||||||
props["default"] = _kdl_value(default)
|
props["default"] = _kdl_value(default)
|
||||||
|
|
||||||
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
with struct_block.node("field", _kdl_string(field_name), **props) as field_block:
|
||||||
is_upload, is_list, is_optional, spec = classify_upload(field_info)
|
|
||||||
if is_upload:
|
|
||||||
_emit_upload_child(field_block, is_list, is_optional, spec)
|
|
||||||
else:
|
|
||||||
_emit_type_child(field_block, field_info.annotation, named_types)
|
_emit_type_child(field_block, field_info.annotation, named_types)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Unit tests for the adapter-agnostic dispatch core."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan_core.auth import AuthConfig, JWTConfig, INVALID, authenticate, create_access_token
|
|
||||||
from mizan_core.authguard import enforce_auth
|
|
||||||
from mizan_core.client.function import client
|
|
||||||
from mizan_core.dispatch import CacheOrchestrator, DispatchRequest, dispatch_call
|
|
||||||
from mizan_core.errors import Forbidden, Unauthorized
|
|
||||||
from mizan_core.invalidation import format_invalidate_header, resolve_invalidation
|
|
||||||
from mizan_core.registry import clear_registry, register
|
|
||||||
|
|
||||||
|
|
||||||
class Ident:
|
|
||||||
def __init__(self, authed=True, staff=False, su=False, pk=1):
|
|
||||||
self.is_authenticated = authed
|
|
||||||
self.is_staff = staff
|
|
||||||
self.is_superuser = su
|
|
||||||
self.pk = pk
|
|
||||||
|
|
||||||
|
|
||||||
# ─── authguard ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_required_anonymous():
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
enforce_auth(None, True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_required_authenticated():
|
|
||||||
enforce_auth(Ident(), True) # no raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_staff_denied_then_allowed():
|
|
||||||
with pytest.raises(Forbidden):
|
|
||||||
enforce_auth(Ident(staff=False), "staff")
|
|
||||||
enforce_auth(Ident(staff=True), "staff")
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_superuser():
|
|
||||||
with pytest.raises(Forbidden):
|
|
||||||
enforce_auth(Ident(su=False), "superuser")
|
|
||||||
enforce_auth(Ident(su=True), "superuser")
|
|
||||||
|
|
||||||
|
|
||||||
def test_auth_callable_false_and_raise():
|
|
||||||
with pytest.raises(Forbidden):
|
|
||||||
enforce_auth(Ident(), lambda r: False)
|
|
||||||
with pytest.raises(Forbidden, match="custom"):
|
|
||||||
enforce_auth(Ident(), lambda r: (_ for _ in ()).throw(PermissionError("custom")))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── authenticate / INVALID sentinel ────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _cfg():
|
|
||||||
return AuthConfig(jwt=JWTConfig(private_key="k" * 32, public_key="k" * 32))
|
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_jwt_ok():
|
|
||||||
cfg = _cfg()
|
|
||||||
tok = create_access_token("7", "sess", cfg.jwt, is_staff=True)
|
|
||||||
ident = authenticate({"Authorization": f"Bearer {tok}"}, cfg)
|
|
||||||
assert ident.pk == 7 and ident.is_staff and ident.is_authenticated
|
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_bad_token_is_invalid_sentinel():
|
|
||||||
assert authenticate({"Authorization": "Bearer garbage"}, _cfg()) is INVALID
|
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_no_token_is_none():
|
|
||||||
assert authenticate({}, _cfg()) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── invalidation + header ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalidation_three_tier_and_header():
|
|
||||||
clear_registry()
|
|
||||||
UserCtx = "user"
|
|
||||||
|
|
||||||
class Out(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
|
|
||||||
@client(context=UserCtx)
|
|
||||||
def user_profile(request, user_id: int) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
@client(affects=UserCtx)
|
|
||||||
def update_profile(request, user_id: int, name: str) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
register(user_profile, "user_profile")
|
|
||||||
register(update_profile, "update_profile")
|
|
||||||
|
|
||||||
# Tier 1: user_id matches context param → scoped
|
|
||||||
inv = resolve_invalidation(update_profile, {"user_id": 5, "name": "x"})
|
|
||||||
assert inv == [{"context": "user", "params": {"user_id": 5}}]
|
|
||||||
assert format_invalidate_header(inv) == "user;user_id=5"
|
|
||||||
|
|
||||||
# Tier 3: no matching param → broad
|
|
||||||
inv2 = resolve_invalidation(update_profile, {"name": "x"})
|
|
||||||
assert inv2 == ["user"]
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── dispatch_call end to end ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_call_auth_and_invalidation():
|
|
||||||
clear_registry()
|
|
||||||
|
|
||||||
class Out(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
|
|
||||||
@client(context="user")
|
|
||||||
def user_profile(request, user_id: int) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
@client(affects="user", auth="staff")
|
|
||||||
def secure_update(request, user_id: int) -> Out:
|
|
||||||
return Out(ok=True)
|
|
||||||
|
|
||||||
register(user_profile, "user_profile")
|
|
||||||
register(secure_update, "secure_update")
|
|
||||||
|
|
||||||
cache = CacheOrchestrator(None, None)
|
|
||||||
|
|
||||||
# non-staff rejected
|
|
||||||
with pytest.raises(Forbidden):
|
|
||||||
asyncio.run(dispatch_call(
|
|
||||||
DispatchRequest(identity=Ident(staff=False), args={"user_id": 1}),
|
|
||||||
"secure_update", cache,
|
|
||||||
))
|
|
||||||
|
|
||||||
# staff passes, invalidation resolved
|
|
||||||
res = asyncio.run(dispatch_call(
|
|
||||||
DispatchRequest(identity=Ident(staff=True), args={"user_id": 1}),
|
|
||||||
"secure_update", cache,
|
|
||||||
))
|
|
||||||
assert res.kind == "rpc" and res.data == {"ok": True}
|
|
||||||
assert res.invalidate == [{"context": "user", "params": {"user_id": 1}}]
|
|
||||||
assert res.invalidate_header == "user;user_id=1"
|
|
||||||
clear_registry()
|
|
||||||
2445
cores/mizan-rust-ssr/Cargo.lock
generated
Normal file
2445
cores/mizan-rust-ssr/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
cores/mizan-rust-ssr/Cargo.toml
Normal file
22
cores/mizan-rust-ssr/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "mizan-rust-ssr"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Mizan SSR engine — embeds a deno_core V8 runtime (with deno_web) to render the build-time JS bundle to HTML in-process. No node, no bun, at serve time."
|
||||||
|
license = "Elastic-2.0"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
# deno_core + deno_web are added via `cargo add` so the resolver pins a
|
||||||
|
# version-matched pair (deno_web constrains deno_core); the web-platform
|
||||||
|
# globals react-dom/server.browser needs (TextEncoder, timers, MessagePort,
|
||||||
|
# streams) come from deno_web as real implementations, not hand-rolled shims.
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
deno_core = "0.403.0"
|
||||||
|
deno_web = "0.281.0"
|
||||||
|
deno_webidl = "0.250.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] }
|
||||||
133
cores/mizan-rust-ssr/src/lib.rs
Normal file
133
cores/mizan-rust-ssr/src/lib.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! Mizan SSR engine.
|
||||||
|
//!
|
||||||
|
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
|
||||||
|
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
|
||||||
|
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
|
||||||
|
//! global render function; the engine evals it once and calls it per request.
|
||||||
|
//! No external JS runtime — node and bun are build-time tools only.
|
||||||
|
//!
|
||||||
|
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
|
||||||
|
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
|
||||||
|
//! web-platform implementations, not shims (a partial polyfill is
|
||||||
|
//! silent-failure-shaped: it passes until a render path hits the gap).
|
||||||
|
//!
|
||||||
|
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
|
||||||
|
//! per-render data crosses as a `v8::json::parse`d value passed as a function
|
||||||
|
//! argument, so a prop string has no source to break out of — code injection
|
||||||
|
//! is structurally absent, not filtered.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use deno_core::{v8, JsRuntime, RuntimeOptions};
|
||||||
|
use deno_web::{BlobStore, InMemoryBroadcastChannel};
|
||||||
|
|
||||||
|
/// Install the web-platform globals react-dom touches at module-init, pulled
|
||||||
|
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
|
||||||
|
/// registers these as lazy modules and installs nothing on `globalThis` until
|
||||||
|
/// asked — this is the minimal bootstrap that asks.
|
||||||
|
const INSTALL_WEB_GLOBALS: &str = r#"{
|
||||||
|
const lazy = (s) => Deno.core.loadExtScript(s);
|
||||||
|
const mp = lazy("ext:deno_web/13_message_port.js");
|
||||||
|
globalThis.MessageChannel = mp.MessageChannel;
|
||||||
|
globalThis.MessagePort = mp.MessagePort;
|
||||||
|
const te = lazy("ext:deno_web/08_text_encoding.js");
|
||||||
|
globalThis.TextEncoder = te.TextEncoder;
|
||||||
|
globalThis.TextDecoder = te.TextDecoder;
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
|
||||||
|
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
||||||
|
/// means an engine is not `Send`; hold one per worker thread).
|
||||||
|
pub struct SsrEngine {
|
||||||
|
runtime: JsRuntime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SsrEngine {
|
||||||
|
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
|
||||||
|
pub fn new(bundle: String) -> Result<Self> {
|
||||||
|
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||||
|
extensions: vec![
|
||||||
|
deno_webidl::deno_webidl::init(),
|
||||||
|
deno_web::deno_web::init(
|
||||||
|
Arc::new(BlobStore::default()),
|
||||||
|
None, // maybe_location
|
||||||
|
false, // enable_css_parser_features
|
||||||
|
InMemoryBroadcastChannel::default(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
runtime
|
||||||
|
.execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
|
||||||
|
.context("installing web-platform globals")?;
|
||||||
|
runtime
|
||||||
|
.execute_script("[mizan:bundle]", bundle)
|
||||||
|
.context("evaluating the SSR bundle")?;
|
||||||
|
Ok(Self { runtime })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
|
||||||
|
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
||||||
|
/// argument — never spliced into evaluated source.
|
||||||
|
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
||||||
|
deno_core::scope!(scope, &mut self.runtime);
|
||||||
|
let context = scope.get_current_context();
|
||||||
|
let global = context.global(scope);
|
||||||
|
|
||||||
|
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
|
||||||
|
let func_val = global
|
||||||
|
.get(scope, key.into())
|
||||||
|
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
|
||||||
|
let func: v8::Local<v8::Function> = func_val
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow!("renderApp is not a function"))?;
|
||||||
|
|
||||||
|
let props_str = v8::String::new(scope, props_json).context("intern props")?;
|
||||||
|
let props = v8::json::parse(scope, props_str)
|
||||||
|
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
|
||||||
|
|
||||||
|
let recv = v8::undefined(scope).into();
|
||||||
|
let result = func
|
||||||
|
.call(scope, recv, &[props])
|
||||||
|
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
|
||||||
|
Ok(result.to_rust_string_lossy(scope))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renders_react_bundle_in_embedded_v8() {
|
||||||
|
let bundle = std::fs::read_to_string(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixture/bundle.js"
|
||||||
|
))
|
||||||
|
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
|
||||||
|
|
||||||
|
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||||
|
let html = engine.render(r#"{"name":"World"}"#).expect("render");
|
||||||
|
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn props_cannot_inject_code() {
|
||||||
|
// A prop value that would break out of a string-built `renderApp(...)`
|
||||||
|
// call. Through the value-call path it is inert data: it reaches the
|
||||||
|
// component as a string, never as source.
|
||||||
|
let bundle = std::fs::read_to_string(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/tests/fixture/bundle.js"
|
||||||
|
))
|
||||||
|
.expect("fixture bundle");
|
||||||
|
|
||||||
|
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||||
|
let html = engine
|
||||||
|
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
||||||
|
.expect("render");
|
||||||
|
// The payload rendered as text; it did not execute.
|
||||||
|
assert!(html.contains("__pwned"));
|
||||||
|
}
|
||||||
|
}
|
||||||
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
3
cores/mizan-rust-ssr/tests/fixture/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
bundle.js
|
||||||
7
cores/mizan-rust-ssr/tests/fixture/Hello.js
Normal file
7
cores/mizan-rust-ssr/tests/fixture/Hello.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createElement } from "react"
|
||||||
|
|
||||||
|
// A trivial component: props in, element out. The keystone only needs to prove
|
||||||
|
// a real React tree renders to HTML inside a bare JS context.
|
||||||
|
export function Hello({ name }) {
|
||||||
|
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
|
||||||
|
}
|
||||||
8
cores/mizan-rust-ssr/tests/fixture/entry.js
Normal file
8
cores/mizan-rust-ssr/tests/fixture/entry.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server.browser"
|
||||||
|
import { createElement } from "react"
|
||||||
|
import { Hello } from "./Hello.js"
|
||||||
|
|
||||||
|
// The bundle exposes one global the embedded engine calls. No module system at
|
||||||
|
// runtime — the engine receives a bare script that defines `renderApp`. This is
|
||||||
|
// the production shape in miniature: build-time bundle, runtime eval.
|
||||||
|
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))
|
||||||
7
cores/mizan-rust-ssr/tests/fixture/package.json
Normal file
7
cores/mizan-rust-ssr/tests/fixture/package.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
cores/mizan-rust-ssr/tests/fixture/runner.cjs
Normal file
29
cores/mizan-rust-ssr/tests/fixture/runner.cjs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Proxy for the embedded-V8 runtime: a bare global context with no Node
|
||||||
|
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
|
||||||
|
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
|
||||||
|
// (bundle defines a global render fn over a bare context) does not.
|
||||||
|
const fs = require("fs")
|
||||||
|
const vm = require("vm")
|
||||||
|
|
||||||
|
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
|
||||||
|
|
||||||
|
// The minimal host globals React's bundle touches at init / sync render. The
|
||||||
|
// rusty_v8 engine must provide the same set — this list is the spec for it.
|
||||||
|
const sandbox = {
|
||||||
|
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
|
||||||
|
TextEncoder, TextDecoder,
|
||||||
|
}
|
||||||
|
sandbox.globalThis = sandbox
|
||||||
|
|
||||||
|
vm.createContext(sandbox)
|
||||||
|
vm.runInContext(code, sandbox)
|
||||||
|
|
||||||
|
const html = sandbox.renderApp({ name: "World" })
|
||||||
|
console.log("RENDERED:", html)
|
||||||
|
|
||||||
|
const expected = '<div id="greeting">Hello, World!</div>'
|
||||||
|
if (html !== expected) {
|
||||||
|
console.error("MISMATCH — expected:", expected)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")
|
||||||
54
cores/mizan-rust-ssr/tests/no_rsc.rs
Normal file
54
cores/mizan-rust-ssr/tests/no_rsc.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
|
||||||
|
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
|
||||||
|
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
|
||||||
|
//!
|
||||||
|
//! React Server Components and the Flight serialization protocol carry
|
||||||
|
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
|
||||||
|
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
|
||||||
|
//! attacker reaches prototype-pollution → RCE.
|
||||||
|
//!
|
||||||
|
//! Mizan renders **synchronously from props** — data is fetched server-side
|
||||||
|
//! through the AFI and passed in, never deserialized from a client payload — so
|
||||||
|
//! it sits structurally outside that attack surface. This test keeps it there:
|
||||||
|
//! it goes red the instant any RSC / Flight / streaming surface enters the
|
||||||
|
//! authored SSR source or its dependencies. Absence is not enough; this is the
|
||||||
|
//! forcing function that makes re-entry loud.
|
||||||
|
|
||||||
|
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
|
||||||
|
const FORBIDDEN: &[&str] = &[
|
||||||
|
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
|
||||||
|
"react-server-dom",
|
||||||
|
"renderToReadableStream",
|
||||||
|
"renderToPipeableStream",
|
||||||
|
"createFromReadableStream",
|
||||||
|
"createFromFetch",
|
||||||
|
"use server",
|
||||||
|
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
|
||||||
|
"next/",
|
||||||
|
"nuxt",
|
||||||
|
"@sveltejs/kit",
|
||||||
|
"sveltekit",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCANNED: &[&str] = &[
|
||||||
|
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/entry.js"),
|
||||||
|
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/Hello.js"),
|
||||||
|
concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/package.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssr_has_no_rsc_or_flight_surface() {
|
||||||
|
for path in SCANNED {
|
||||||
|
let Ok(src) = std::fs::read_to_string(path) else {
|
||||||
|
continue; // a generated/optional file absent is fine; authored source is the point
|
||||||
|
};
|
||||||
|
for needle in FORBIDDEN {
|
||||||
|
assert!(
|
||||||
|
!src.contains(needle),
|
||||||
|
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
|
||||||
|
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
|
||||||
|
classic renderToString-family only, rendered synchronously from props.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -386,18 +386,6 @@ async function resolveHeaders(): Promise<Record<string, string>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Browser-safe `File` check — `File` is undefined under Node/SSR. */
|
|
||||||
function isFile(value: unknown): boolean {
|
|
||||||
return typeof File !== 'undefined' && value instanceof File
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when any arg is a file (or an array containing a file). */
|
|
||||||
function hasFileArg(args: Record<string, any>): boolean {
|
|
||||||
return Object.values(args).some(
|
|
||||||
(v) => isFile(v) || (Array.isArray(v) && v.some(isFile)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
||||||
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
||||||
@@ -409,38 +397,13 @@ export function httpTransport(): MizanTransport {
|
|||||||
return {
|
return {
|
||||||
async call(functionName, args) {
|
async call(functionName, args) {
|
||||||
const headers = await resolveHeaders()
|
const headers = await resolveHeaders()
|
||||||
|
|
||||||
// File-typed args switch the call to multipart/form-data: `fn` and a
|
|
||||||
// JSON `args` part for the non-file fields, plus one part per file
|
|
||||||
// (an array field repeats its part). Otherwise JSON as usual. The
|
|
||||||
// server reconstructs the args dict by merging the file parts back in.
|
|
||||||
let body: BodyInit
|
|
||||||
if (hasFileArg(args)) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('fn', functionName)
|
|
||||||
const jsonArgs: Record<string, any> = {}
|
|
||||||
for (const [key, value] of Object.entries(args)) {
|
|
||||||
if (isFile(value)) {
|
|
||||||
form.append(key, value)
|
|
||||||
} else if (Array.isArray(value) && value.some(isFile)) {
|
|
||||||
for (const item of value) form.append(key, item as Blob)
|
|
||||||
} else {
|
|
||||||
jsonArgs[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
form.append('args', JSON.stringify(jsonArgs))
|
|
||||||
body = form
|
|
||||||
// Content-Type is set by the browser (with the multipart boundary).
|
|
||||||
} else {
|
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
body = JSON.stringify({ fn: functionName, args })
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${config.baseUrl}/call/`, {
|
const res = await fetch(`${config.baseUrl}/call/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body,
|
body: JSON.stringify({ fn: functionName, args }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new MizanError(res.status, await res.text())
|
if (!res.ok) throw new MizanError(res.status, await res.text())
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ fn ts_type_expression(shape: &TypeShape) -> String {
|
|||||||
.map(ts_type_expression)
|
.map(ts_type_expression)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | "),
|
.join(" | "),
|
||||||
TypeShape::Upload(_) => "File".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,9 +118,6 @@ fn py_type_expression(shape: &TypeShape) -> String {
|
|||||||
.map(py_type_expression)
|
.map(py_type_expression)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | "),
|
.join(" | "),
|
||||||
// The Python (PyO3) client is a consumer, not an upload origin; a file
|
|
||||||
// input surfaces as raw bytes on this target.
|
|
||||||
TypeShape::Upload(_) => "bytes".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -440,8 +440,5 @@ fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String {
|
|||||||
// Value so the consumer can match on the runtime variant.
|
// Value so the consumer can match on the runtime variant.
|
||||||
"serde_json::Value".to_string()
|
"serde_json::Value".to_string()
|
||||||
}
|
}
|
||||||
// The Rust adapter does not yet wire multipart; a file input surfaces
|
|
||||||
// as raw bytes until upload dispatch lands on this target.
|
|
||||||
TypeShape::Upload(_) => "Vec<u8>".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,5 @@ fn ts_type_expression(shape: &TypeShape) -> String {
|
|||||||
.map(ts_type_expression)
|
.map(ts_type_expression)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | "),
|
.join(" | "),
|
||||||
TypeShape::Upload(_) => "File".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,15 +46,6 @@ pub enum TypeShape {
|
|||||||
Enum(Vec<String>),
|
Enum(Vec<String>),
|
||||||
/// Multi-arm union with two or more non-null branches.
|
/// Multi-arm union with two or more non-null branches.
|
||||||
Union(Vec<TypeShape>),
|
Union(Vec<TypeShape>),
|
||||||
/// Binary file input. Carries the declarative `File(...)` constraints.
|
|
||||||
Upload(UploadConstraints),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct UploadConstraints {
|
|
||||||
pub max_size: Option<u64>,
|
|
||||||
pub content_types: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -282,7 +273,6 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
|
|||||||
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
|
"list" => Ok(TypeShape::List(Box::new(type_child_of(node, "list")?))),
|
||||||
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
|
"optional" => Ok(TypeShape::Optional(Box::new(type_child_of(node, "optional")?))),
|
||||||
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
|
"enum" => Ok(TypeShape::Enum(parse_string_args(node))),
|
||||||
"upload" => Ok(TypeShape::Upload(parse_upload_constraints(node))),
|
|
||||||
"union" => {
|
"union" => {
|
||||||
let children = node.children()
|
let children = node.children()
|
||||||
.ok_or_else(|| anyhow!("union: missing children"))?;
|
.ok_or_else(|| anyhow!("union: missing children"))?;
|
||||||
@@ -295,20 +285,6 @@ fn parse_type_shape(node: &KdlNode) -> Result<TypeShape> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn parse_upload_constraints(node: &KdlNode) -> UploadConstraints {
|
|
||||||
let max_size = node.entry("max-size")
|
|
||||||
.and_then(|e| e.value().as_integer())
|
|
||||||
.map(|i| i as u64);
|
|
||||||
let content_types = node.children()
|
|
||||||
.map(|children| children.nodes().iter()
|
|
||||||
.filter(|n| n.name().value() == "content-type")
|
|
||||||
.filter_map(|n| first_string_arg(n).ok())
|
|
||||||
.collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
UploadConstraints { max_size, content_types }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
|
fn parse_function(node: &KdlNode) -> Result<MizanFunction> {
|
||||||
let name = first_string_arg(node)
|
let name = first_string_arg(node)
|
||||||
.context("`function` requires a name as its first argument")?;
|
.context("`function` requires a name as its first argument")?;
|
||||||
|
|||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user