Compare commits

...

41 Commits

Author SHA1 Message Date
c15c6f3e14 Clean codegen leftovers from mizan-react after the protocol/ relocation
The codegen used to live in mizan-react before mizan-django before
protocol/mizan-generate. Each move left sediment in the previous
home; the bin entry in particular shadowed mizan-generate's own bin
in node_modules/.bin/, breaking `npx mizan-generate`. Caught at
integration time when the harness install picked up the stale link.

frontends/mizan-react/package.json:
- Removed bin entry pointing at the long-gone ./dist/generator/cli.mjs.
- Simplified the build script — dropped `cpSync('src/generator',
  'dist/generator', ...)`. src/generator hasn't existed in this package
  since the first move; the cpSync would silently fail at every build.
- Removed optionalDependencies (chokidar, minimatch, openapi-typescript) —
  these were codegen-watcher deps, no longer relevant to the React adapter.

examples/{django,fastapi}-react-site/harness/package.json:
- Added `mizan-generate` as a file: devDep so `npx mizan-generate
  --config <config.mjs>` resolves to the right binary in the monorepo.
  Mirrors the install pattern the README documents for downstream users.

Verified: mizan-react vitest 33/33 (78 skipped — integration tests).
Codegen runs from harness via npx for both example apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:24:30 -04:00
cc887fb1f6 Move codegen out of mizan-django: protocol/mizan-generate/
The codegen consumes a schema from any backend and emits typed client
code for any frontend — it doesn't belong inside a backend adapter.
That placement was historical sediment from when there was only a
Django backend; it predates the AFI generalization.

New top-level slot: `protocol/` for protocol-level tooling. Tree is
now:

  backends/    server protocol adapters
  frontends/   client kernel + per-framework adapters
  cores/       shared language-level primitives
  protocol/    protocol-level tooling
  workers/     runtime workers / bridges

Codegen moves to `protocol/mizan-generate/`. Same file layout under
`generator/` (cli.mjs, lib/), preserved via git mv.

Package metadata cleaned up:
- name: "generate" (placeholder) → "mizan-generate"
- description filled in
- type: module (cli.mjs is .mjs ESM, was previously declared "commonjs")
- bin entry added so `npx mizan-generate --config <config.mjs>` works
  once the package is published, instead of `node path/to/cli.mjs`.

Path-reference fixups:
- backends/mizan-django/README.md: `node path/to/...` → `npx mizan-generate`
- backends/mizan-fastapi/README.md: same
- ISSUES.md: file paths in three issue entries
- CLAUDE.md: codegen description + Package Layout section refreshed
  (added protocol/, mizan-fastapi entry, mizan-python entry)
- docs/AFI_ARCHITECTURE.md: Package Layout refreshed identically

Verified codegen runs from new location: regenerated the FastAPI
example harness's api/ output, identical to pre-move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:16:11 -04:00
f0f7a93ed2 Backend adapter READMEs — DX surface + codegen invocation
mizan-django/README.md:
- Updated install path (was pointing at the old `subdirectory=django` git
  layout from before the backends/ restructure).
- Dropped the dead "monorepo root README" link (the root README was
  removed earlier in the substrate-restoration work).
- Fixed the apps.py example — convention is `clients.py` per MIZAN.md,
  not `mizan_clients.py`.
- Added the `mizan_clients()` auto-discovery pattern (it was missing).
- Added a Generate-the-frontend section: config shape + CLI invocation
  + the resulting <MizanContext>/use{Hook}() React surface.
- Tightened decorator-parameter overview to a single block covering the
  full @client surface.

mizan-fastapi/README.md (new):
- Mirrors mizan-django's structure for consistency.
- Opens with the AFI-common scope: forms/channels/shapes/SSR are out of
  scope on the FastAPI side; FastAPI projects use native equivalents.
- Setup shows app.add_exception_handler wiring for MizanError +
  RequestValidationError so every error surface goes through the same
  envelope the kernel parses.
- Calls out explicit register() (no AppConfig.ready() analog on FastAPI;
  registrations live in main.py or an imported clients.py).
- Auth-integration section explains the request.state.user middleware
  contract the executor expects.
- Codegen section shows the source.fastapi config shape that points at
  the new `python -m mizan_fastapi.cli <module>` schema export.
- Closes with pointers to AFI conformance + the e2e harness so a reader
  can verify the adapter's claims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:10:01 -04:00
255e10cb21 mizan-fastapi e2e — example app + Playwright harness, 14/14 green
Demonstration milestone. The substrate work earlier in the session
established that mizan-fastapi can dispatch RPC, bundle context
fetches, and emit invalidation envelopes via TestClient (in-process
ASGI). This commit closes the demonstration gap: a real FastAPI server
on port 8001 + a real React harness on port 5175 + Playwright in real
Chromium, exercising generated hooks.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/cli.py — schema-export CLI:
- `python -m mizan_fastapi.cli <module>` imports the named module
  (triggering @client decorations + register() side effects), then
  prints the OpenAPI schema to stdout. Mirrors mizan-django's
  `manage.py export_mizan_schema` so the codegen consumes either
  backend the same subprocess way.

backends/mizan-django/generate/generator/lib/fetch.mjs — codegen now
dispatches on source.django vs source.fastapi. Refactored the
subprocess plumbing into a shared runSubprocess helper. The codegen
package is still named "mizan-django" by historical accident — it's
the framework-agnostic CLI now (a rename for later).

backends/mizan-fastapi/src/mizan_fastapi/executor.py — bug fix:
mizan_core's @client decorator normalizes auth=True to
meta['auth']='required'. The executor's match was only handling True,
not 'required', so any auth-required endpoint failed with
INTERNAL_ERROR. Now matches both. Caught when wiring up the FastAPI
example backend's whoami fixture; would have surfaced first time any
real FastAPI app used auth=True.

backends/mizan-fastapi/tests/test_dispatch.py — added AuthTests
covering the auth=True path so the bug fix has unit coverage. Suite
now 12/12.

examples/fastapi-react-site/ — parallel to examples/django-react-site/:
- backend/main.py: FastAPI app with 11 @client fixtures matching the
  harness surface (echo, add, multiply, whoami, staff/superuser/
  verified-only, notImplementedFn, buggyFn, permissionCheckFn,
  current_user context). Drops Django-only stuff (forms, channels,
  ws-whoami, session-bound JWT).
- harness/: vite proxy → FastAPI on 8001; generated api/ produced by
  the codegen against fastapi.config.mjs.
- mizan.spec.ts: Playwright suite, 14 tests covering the same axes
  as Django minus channel-chat.
- ContextCurrentUser fixture renders 'loading' until data arrives
  rather than emitting <pre>null</pre> — fixes a race the Django
  harness has too (just doesn't trip in practice).

Verified:
- mizan-fastapi unit:    12/12 (incl. new auth=True coverage)
- mizan-fastapi e2e:     14/14 (Playwright via real Chromium)
- mizan-core unit:       15/15
- mizan-django unit:     348 pass, 21 skip
- AFI conformance:        3/3
- mizan-django e2e:      14/15 (1 skip — channels, deferred)

What remains for FastAPI side:
- Dockerfile.test + docker-compose.test.yml so CI can run the e2e
  in the same containerized way as the Django example.
- Makefile test-integration target for symmetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:05:18 -04:00
19ce4d4a2a Restore KDL-as-IR to AFI_ARCHITECTURE
Re-stating the architectural intent that got silently swapped to "JSON
schema" during the doc-restoration commit (6eca514). The original
AFI_ARCHITECTURE positioned KDL as the LLVM-IR-equivalent of the
system; I read the stale-package-name note at the bottom as
invalidating the whole architecture description and substituted
JSON-schema references thinking that was the more-current intent.

That conflated implementation drift (mizan-ast/mizan-schema package
names) with substrate intent (KDL is the IR). The packages had
drifted; the IR target had not. Restoring the section.

Frames KDL-as-IR explicitly as forward-direction. The current
OpenAPI/JSON-Schema codegen path is transitional — it's the layered
indirection where adapter divergence lives (AFI conformance suite
demonstrates this). Real KDL implementation lives in a forthcoming
mizan-schema package; today's path is sediment around the eventual
substrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:19:40 -04:00
0a95f3c860 AFI conformance test suite
Substrate-level gate: same @client fixture registered in both backends
emits equivalent schemas, therefore the codegen produces equivalent
TypeScript regardless of which backend the frontend is generated against.
Catches adapter symmetry problems (Pydantic→OpenAPI converter divergence,
metadata leakage, ordering non-determinism) without docker, browser, or
Playwright.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/schema.py — build_schema():
- Builds OpenAPI 3.0 from registered Mizan functions, mirroring the
  shape mizan-django's export emits.
- Drives FastAPI's native OpenAPI generation by registering a stub POST
  endpoint per function with its Input/Output Pydantic models, then
  appends x-mizan-functions and x-mizan-contexts extensions.
- Param-elevation logic mirrors mizan-django/src/mizan/export/__init__.py
  exactly (sharedBy tracking, required iff every function in context has
  the param).
- snake_to_camel and metadata field shapes match Django for byte-equality
  on the AFI surface.

tests/afi/ — the conformance harness:
- fixture.py: 5 @client functions covering the protocol axes (plain,
  context, mutation+affects). No channels/forms — those aren't AFI-common.
- django_app/: minimal Django project (settings, urls, AppConfig.ready
  registers the fixture). manage.py adds tests/afi/ to sys.path so both
  backends import the same fixture module.
- fastapi_app.py: thin make_app() that registers fixture and mounts router.
- schema_normalizer.py: drops backend-specific framing — Ninja-vs-FastAPI
  envelope differences (info/servers/tags), Django-only function fields
  (form metadata), x-mizan-channels. Plus afi_subset() and
  function_io_schemas() helpers for narrower comparisons.
- test_codegen_parity.py: three gates
  1. x-mizan-functions match across backends
  2. x-mizan-contexts match across backends
  3. Per-function Input/Output OpenAPI schemas match (what codegen feeds
     to openapi-typescript for type generation)

The full normalized OpenAPI envelopes do diverge — FastAPI adds
HTTPValidationError, the two converters wrap things slightly differently
in non-AFI-essential ways. That's not in the test scope. The codegen
only consumes x-mizan-functions, x-mizan-contexts, and the per-function
type schemas; those are what the test gates.

Makefile: test-afi target added; rolls into the test aggregate.

Verified: 3/3 conformance tests pass. Other surfaces unaffected —
mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:59:01 -04:00
aaaf80cdbf End-to-end: harness Playwright suite green (14 pass, 1 skip)
After the React-codegen rework, ran the full e2e harness against the
docker-stack backend. Surfaced and fixed real friction:

mizan-base/src/index.ts (kernel):
- MizanError now parses both error envelopes — the FastAPI shape
  ({"error": {"code", "message", "details"}}) and the Django shape
  ({"error": true, "code", "message", "details"}). Exposes .code and
  .details on the thrown error so consumer code can branch on them.
  This was needed for the harness's `instanceof MizanError && error.code
  === 'NOT_FOUND'` pattern to work; the previous MizanError only carried
  status + raw body, leaving callers to parse the body themselves.

examples/django-react-site/Dockerfile.test:
- Backend image now copies and installs cores/mizan-python before
  installing mizan-django (which imports from mizan_core after the
  Layer 1 extraction).

harness/src/fixtures.tsx:
- useRun helper updated for the new mutation-hook shape: pulls
  { mutate } off the hook result instead of treating the hook return
  as a callable. Same for ValidationError fixture.

mizan.spec.ts:
- DjangoError → MizanError (kernel error class is backend-agnostic).
- Form tests removed (forms codegen deferred per Blazr scope).
- Channel test marked test.skip (channels deferred per Blazr scope).

.gitignore: ignore Playwright test-results/.

Final verification across all surfaces:
- mizan-core unit:       15/15
- mizan-django unit:     348 pass, 21 skip
- mizan-fastapi unit:    11/11
- mizan-ts edge-compat:  34/34 (cross-language HMAC pin)
- harness e2e (Playwright): 14/15 (1 skip = channels deferred)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:38:52 -04:00
2982741aad React wrapper-layer codegen — restore the idioms over the kernel
The harness was written against the MIZAN.md oracle (<MizanContext>,
provider-per-context, useMizan, etc.) but the codegen had been narrowed
to just hooks-direct-on-kernel after the kernel split. Restoring the
React-idiomatic layer on top of the kernel.

backends/mizan-django/generate/generator/lib/adapters/react.mjs:
- Emits <MizanContext baseUrl="…"> root provider that calls configure()
  once and (if a global context is registered) wraps children in
  <GlobalContextProvider>.
- Emits <GlobalContextProvider> + <{Name}Context> per named context —
  kernel registration happens once per provider mount, not per hook
  call. Consumers read from React Context.
- Base hooks: useGlobalContext() / use{Name}Context() return full
  ContextState<T> (data + status + error).
- Convenience hooks per context-function (use{Fn}() returns data | null)
  and per regular function/mutation (use{Fn}() returns
  { mutate, isPending, error }).
- useMizan() returns { call, fetch } as an imperative escape hatch
  for test harnesses or rare cases where typed hooks don't fit.
- Re-exports MizanError, configure, initSession, ContextState from
  @mizan/base.

backends/mizan-django/generate/generator/cli.mjs:
- After Stage 2, appends `export * from './<adapter>'` to index.ts so
  `import { useEcho, MizanContext } from './api'` works as a barrel.

Bug fixes surfaced during integration:
- react.mjs was generating `from '../index'` (wrong path); flat layout
  needs `./index`.
- harness django.config.mjs had `output: 'src/api/generated.ts'` which
  the codegen treated as a directory; corrected to `output: 'src/api'`.
- example testapp/clients.py imported from the deleted
  mizan.setup.registry path; routed through mizan.setup aggregator.

harness/package.json: adds @mizan/base dep so the generated react.tsx
can resolve its kernel imports.

harness/src/fixtures.tsx:
- DjangoError → MizanError (kernel error class, backend-agnostic).
- useChatChannel sourced from ./api/channels.hooks directly (not
  re-exported from the unified index for now).
- Form fixtures removed — forms codegen deferred per Blazr scope.

Verified: harness `vite build` succeeds, 53 modules transformed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:21:49 -04:00
63c9a9c4ce mizan-fastapi: more Pythonic and declarative
Reworked the MVP code along the lines Ryth flagged. Same behavior
(11/11 tests still pass), tighter idiom.

executor.py:
- Replaced FunctionResult / FunctionError dataclasses with a MizanError
  exception hierarchy (NotFound, BadRequest, ValidationFailed,
  Unauthorized, Forbidden, NotImplementedYet, InternalError). Each
  carries its own ErrorCode + HTTP status; the dispatcher path raises
  rather than returning sentinel objects.
- Auth check uses match/case for the requirement (True / 'staff' /
  'superuser' / callable / other) — single declarative dispatch instead
  of an if/elif chain.
- Broke up the single 80-line execute_function into focused helpers:
  _resolve_function, _enforce_auth, _validate_input, _serialize,
  _invalidation_target. The execute_function body now reads as five
  declarative steps.
- Input validation uses Pydantic's model_fields[name].is_required()
  directly and a list comprehension for required-field reporting,
  instead of round-tripping through model_json_schema().

router.py:
- POST /call/ now declares its body as a Pydantic CallBody model;
  FastAPI handles parsing + envelope validation. No more manual
  await request.json() + dict[get] dancing.
- Endpoint bodies shrink to 3-5 lines each. Context fetch uses a
  dict comprehension over the function group.
- mizan_exception_handler renders MizanError to the protocol's
  {error: {code, message, details}} envelope.
- mizan_validation_handler maps FastAPI's RequestValidationError to
  the same envelope under BAD_REQUEST so the wire format is uniform
  whether the failure is body-shape or business validation.

__init__.py: exposes the full exception hierarchy + both handlers
so consumers can wire them onto their FastAPI app declaratively:

    app.add_exception_handler(MizanError, mizan_exception_handler)
    app.add_exception_handler(RequestValidationError, mizan_validation_handler)

Verified: mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:51:03 -04:00
4e4d1bb6b1 Build mizan-fastapi MVP — HTTP RPC + context bundling
The Blazr-critical surface for FastAPI. Forms, Channels, Shapes, SSR,
and MWT are out of scope (Ryth's call: defer until Blazr exercises
them; FastAPI projects use native equivalents anyway).

What ships:
- POST /api/mizan/call/      RPC dispatch with Pydantic input validation
- GET  /api/mizan/ctx/{name}/ bundled context fetch (all functions in
                              the named context, parallel-evaluated, single
                              JSON response)
- JSON-body invalidation transport (the 'invalidate' field on mutation
  responses, with auto-scoping when mutation arg names match context params)
- Auth check infrastructure expecting request.state.user populated by
  FastAPI middleware/deps (matches FastAPI idioms)
- Cache-Control: no-store on all responses

Built on existing mizan-core: registry (function lookup, context groups,
invalidation metadata), client.function (the @client decorator + ServerFunction
+ _FunctionWrapper). No code copied or duplicated from mizan-django — the
shared substrate is genuinely shared.

Package layout:
  backends/mizan-fastapi/
    pyproject.toml         distribution=mizan-fastapi, module=mizan_fastapi
    src/mizan_fastapi/
      executor.py          dispatch + auth + invalidation
      router.py            FastAPI APIRouter with the two endpoints
    tests/test_dispatch.py 11 e2e tests against TestClient

Test fixture establishes the registration pattern: explicit
register(fn_class, "name") after each @client. mizan-fastapi doesn't
ship discovery — apps register their functions explicitly. (mizan-django
keeps its DjangoAppVisitor discovery; FastAPI's lack of an app system
makes auto-discovery less natural.)

Makefile: install + test targets now include mizan-fastapi alongside
the other packages. New test-core / test-fastapi targets added for
symmetry.

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-fastapi: 11/11
- mizan-ts edge-compat: 34/34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:19 -04:00
dd41f0c25f Extract client/function.py to mizan-core (Tier B)
The @client decorator + ServerFunction base + composition machinery is
mostly framework-agnostic. The only Django couplings were typing
(HttpRequest in __init__ and submit_handler signatures) and runtime
view-path detection (HttpResponseBase isinstance/issubclass checks).

Replaced both with backend-extension hooks:

- HttpRequest type hints → Any. Type Protocol can be tightened later.
- HttpResponseBase view-path detection → set_framework_response_base(cls)
  hook in mizan_core.client.function. Backends register their framework's
  response base at import time. is_framework_response(obj_or_cls) handles
  both instance and subclass checks via the registered base.

mizan-django registers HttpResponseBase via mizan/client/__init__.py
before any @client-decorated code is loaded. FastAPI would similarly
register starlette.responses.Response.

Direct consumers updated:
- mizan/setup/discovery.py: ServerFunction import path
- mizan/forms/__init__.py: ServerFunction + create_form_functions imports

mizan/client/__init__.py keeps its public re-export surface stable so
'from mizan.client import client, ServerFunction, …' continues to work
for downstream Django consumers.

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:39:48 -04:00
76fce2dc85 Split the registry — function/composition core + backend extensions
The original registry tangled function, channel, composition, and form
registration in a single file with polymorphic register() dispatch.
That predates the household discipline; it was the design that was
supposed to ship but didn't. Re-implementing the original intent.

cores/mizan-python/src/mizan_core/registry.py (new):
- _functions, _compositions dicts
- register() — ServerFunction-only, no polymorphic dispatch
- register_as(), register_compose()
- register_extension(name, extension) — hook interface
- get_function/get_compose/get_all_functions/get_all_compositions
- get_contexts, get_context_groups
- get_registry, get_schema — aggregate extension contributions
- validate_registry, clear_registry — cascade-clear extensions

RegistryExtension Protocol:
- schema() returns the extension's schema subdict (keyed under its name)
- clear() resets extension state (called by clear_registry)

mizan-django/src/mizan/channels/__init__.py:
- _ChannelsExtension wraps the channel _registry, plugs into core via
  register_extension('channels', ...). Schema output preserves the
  same shape codegen consumed before (snake_case keys, type+bidirectional).

mizan-django/src/mizan/forms/__init__.py:
- register_form() and get_forms() helpers moved here (were in setup/registry.py)
- Both use mizan_core.registry under the hood. Forms don't need a
  separate extension because form sub-functions register as regular
  ServerFunctions with meta.form set.

mizan-django/src/mizan/setup/registry.py: deleted.
mizan-django/src/mizan/setup/__init__.py: re-exports the registry helpers
from mizan_core.registry / mizan.channels / mizan.forms — the Django
adapter's curated public API surface stays stable for users.

Consumers updated: ~10 files imported `from mizan.setup.registry`;
all switched to direct imports from mizan_core.registry, mizan.channels,
or mizan.forms as appropriate. ChannelTests in test_core.py rewritten
to use mizan.channels.register directly (no more polymorphic
@register_as on ReactChannel subclasses).

Verified:
- mizan-core: 15/15
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts edge-compat: 34/34 (cross-language pin holds)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:21:16 -04:00
9150cdc5ee Extract cache/backend.py to mizan-core (Tier A)
cache/backend.py is pure framework-agnostic key-value abstraction —
CacheBackend Protocol, MemoryCache, RedisCache. No Django imports.
Moves to cores/mizan-python/src/mizan_core/cache/backend.py with no
content changes; mizan-django re-imports.

Verified: mizan-core 15/15, mizan-django 348 pass / 21 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:23:01 -04:00
37e61c646b Extract Layer 1 to cores/mizan-python (mizan-core)
Pull cache/keys.py (HMAC cache key derivation) and mwt.py (Mizan Web Token)
out of backends/mizan-django and into a new cores/mizan-python package.
mizan-django re-imports them via the new mizan_core module.

Naming: directory cores/mizan-python/, distribution mizan-core, importable
module mizan_core. mizan-django keeps its existing 'mizan' distribution slot
on PyPI; the two coexist as distinct packages.

Wiring:
- backends/mizan-django/pyproject.toml gains a 'mizan-core' dep with a
  [tool.uv.sources] path entry (editable install from ../../cores/mizan-python).
- Makefile install target prepends 'cd cores/mizan-python && uv pip install -e .'
- 3 import sites in mizan-django updated: cache/__init__.py, jwt/functions.py,
  client/executor.py — all now import from mizan_core.

Test split:
- 3 unit-test classes (CacheKeyDerivationTests, MWTCreationTests,
  PermissionKeyTests) move to cores/mizan-python/tests/, rewritten against
  unittest.TestCase (no Django dep). The cross-language pin test (pinned
  HMAC hex digests against mizan-ts) moves with CacheKeyDerivationTests.
- Integration tests stay in mizan-django (CacheBackendTests, CachePurgeTests,
  CacheIntegrationTests, RevParameterTests, MWTAuthIntegrationTests) — they
  need the Django request flow.

Verified:
- mizan-core: 15/15 pass (incl. cross-language pin)
- mizan-django: 348 pass, 21 skip, 0 fail
- mizan-ts: edge-compat 34/34 pass — protocol invariant holds, the moved
  Python derive_cache_key still produces the exact hex digests TS pins against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:32:54 -04:00
9d2781b52c Catch missed content edits from tree restructure
The fe39fcb commit captured the file moves (git mv stages those automatically)
but didn't catch the content edits I made afterward — npm package rename
(@mizan/runtime → @mizan/base), path updates in Makefile/Dockerfile/examples,
and doc updates were all left unstaged at commit time.

This commit lands those:
- npm rename: 3 frontend package.jsons (base/vue/svelte) + mizan-base/src/index.ts + 4 codegen templates
- path updates: Makefile, Dockerfile.test, two Gitea workflows, four example/harness configs
- doc updates: CLAUDE.md, ROADMAP.md, ISSUES.md, docs/AFI_ARCHITECTURE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 01:26:04 -04:00
fe39fcb229 Restructure tree by role; rename mizan-runtime → mizan-base
packages/ flattens into:
  backends/   server protocol adapters (mizan-django, mizan-ts)
  frontends/  client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
  workers/    runtime workers (mizan-ssr)
  cores/      shared language-level primitives (empty for now; mizan-python forthcoming)

The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.

Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).

Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.

Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:55:37 -04:00
6eca514777 Restore documentation layer — match current substrate
ROADMAP: done items moved out of "Next" (codegen rewrite, SSR bridge,
edge manifest, X-Mizan-Invalidate, return-type branching, affects_params,
kernel extraction, two-stage codegen, mizan-ts). Real "Next" in:
framework-adapter wrapper layer (MizanContext + useMizan + DjangoError
on top of the kernel) for React/Vue/Svelte; A1–A4 from ISSUES.md.

CLAUDE: 4-package layout replaced with the actual 7-package layered
architecture (backend protocol adapters + frontend kernel + framework
adapters + SSR worker). "STALE codegen" section rewritten to describe
what's emitted vs. the wrapper layer that isn't yet.

docs/ now tracked (6 files). AFI_ARCHITECTURE rewritten — replaced
the speculative `mizan-ast`/`mizan-csr`/`mizan-rpc`/`mizan-schema`
package names with the real layout, dropped KDL-schema language for
the actual schema-export format. The other 5 docs/ files were already
current and are tracked as-is.

ARCHITECTURE-REWORK.md deleted — same expert review is re-tracked in
the fresher ISSUES.md, two parallel trackers was sediment.

README.md deleted — drift was beyond surgical fixes (`mizan_clients.py`
convention, `<DjangoContext>` provider, removed `@compose` and
`context='local'`, wrong codegen output filenames, 3-package structure
vs. 7). Rewrite waits for the wrapper-layer codegen to land so
user-facing examples reflect reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:10:12 -04:00
5c1c583164 Fix example backend asgi.py — import testapp.clients (was testapp.mizan_clients)
Discovery convention per MIZAN.md is `clients.py`. The example backend's
asgi.py was still importing the older `mizan_clients` name, causing the
example Django container to fail to start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:09:55 -04:00
2d7cf3eb39 Document architectural debt and test coverage gaps in ISSUES.md
Added 6 architectural/cleanup items (A1-A6):
- Legacy MizanProvider not yet removed
- Allauth pending extraction to own package
- Forms codegen not adapted to kernel
- Vue/Svelte adapters not validated end-to-end
- ROADMAP.md and CLAUDE.md likely stale

Added 12 test coverage gaps (T1-T12):
- No tests for C6 kernel state machine
- No tests for generated Vue/Svelte output
- No tests verifying recent fixes (C3/C4/C5/C7/H3/H10/H11/H13)
- No end-to-end integration test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:12:02 -04:00
bb88fd984b C6: Runtime kernel owns data, status, error — adapters subscribe
The kernel is no longer a blind refetch pipe. Each context entry has:
  { data, status: idle|loading|success|error, error }

registerContext() returns { getState, subscribe, refetch, unregister }.
Adapters subscribe to state changes via callbacks. The kernel does
the fetch and notifies subscribers with the new state.

React adapter uses useSyncExternalStore for tear-free reads.
Vue adapter uses ref + subscribe callback.
Svelte adapter uses readable store backed by kernel subscription.

All three adapters also get:
- Mutation hooks with { mutate, isPending, error } (fixes H5)
- Vue: onServerPrefetch for Nuxt SSR (fixes M9)
- Svelte: readable store auto-cleans up on unsubscribe (fixes H9)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:38:53 -04:00
07f1c7842c Update ISSUES.md — 16 fixed, 22 remaining
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:30:52 -04:00
9c837cf285 Fix H3, H6, H11, H13, M11, M18 — quick wins from expert review
H3: mizanFetch retries 2x on server errors (5xx) and network
failures. 200ms/400ms backoff. Mutations NOT retried (not idempotent).

H6: refreshContext now uses GET /ctx/<name>/ instead of POST /call/.
Context reads go to the context endpoint, not the mutation endpoint.

H11: Python cache key derivation normalizes True→"true",
False→"false", None→"null" for cross-language HMAC consistency
with JavaScript's String() behavior.

H13: Forms isValid now checks that all required fields have been
touched, not just that touched fields have no errors.

M11: execute_function return type updated to include HttpResponseBase
for view-path functions.

M18: registerContext cleanup uses ?. instead of ! to prevent crash
if Map was cleared (already fixed in H2 commit but documenting).

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:30:10 -04:00
cdd15b3810 Fix critical issues C1-C5, C7, H1, H2, H4, H10
C1+C7: Cache purge now passes user_id and works for view-path
mutations. Extracted _purge_cache_for_invalidation() shared helper
used by both RPC and view-path branches.

C2: initSession retries 3x with backoff. Resets on total failure
so next call tries again instead of permanently broken CSRF.

C3: SSR template backend injects __MIZAN_SSR_DATA__ script tag
with serialized props for client-side hydration.

C4: SSR bridge uses _write_lock to serialize stdin writes from
concurrent Django threads. Prevents JSON interleaving.

C5: SSR bridge registers atexit handler for process cleanup.
No more orphaned Bun processes on Django reload/shutdown.

H1: pendingScoped changed from Map to Array — multiple scoped
invalidations for the same context no longer overwrite.

H2: registerContext uses stableKey() (sorted JSON) instead of
bare JSON.stringify. Property order no longer matters.

H4: Named context providers skip refetch if SSR data exists
(matches global context behavior).

H10: _meta always assigned as fresh dict, preventing shared-dict
mutation across ServerFunction subclasses.

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:24:41 -04:00
499aa0e038 Add ISSUES.md — expert review findings across 7 domains
7 critical, 13 high, 18 medium issues identified by:
Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte

Critical: scoped cache purge broken, initSession swallows errors,
SSR hydration never injected, SSR bridge thread-unsafe + leaks
processes, no loading/error states in kernel, view-path mutations
skip cache purge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:18:57 -04:00
c20de182e1 Two-stage codegen: React + Vue + Svelte from one schema
Stage 1 (framework-agnostic):
  types.ts           — OpenAPI interfaces
  contexts/<name>.ts — fetchXxxContext(params) using mizanFetch
  mutations/<name>.ts — callXxx(args) using mizanCall
  functions/<name>.ts — callXxx(args) using mizanCall
  index.ts           — re-exports

Stage 2 (per framework):
  react.tsx  — hooks + context providers + SSR hydration
  vue.ts     — composables with provide/inject + ref/computed
  svelte.ts  — writable/derived store factories

New packages:
  mizan-runtime — the kernel (~200 lines, zero framework deps)
    configure(), initSession(), registerContext(), invalidate(),
    mizanFetch(), mizanCall(), MizanError
  mizan-vue     — Vue adapter (package.json, codegen template)
  mizan-svelte  — Svelte adapter (package.json, codegen template)

CLI: mizan-generate --target react,vue,svelte
Config: target: 'react' (default) in django.config.mjs

Verified: codegen produces 33 functions across 2 contexts,
14 plain functions, 0 mutations, generating all three Stage 2
outputs from one schema fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:09:35 -04:00
6108845d99 Rename all djarea references to mizan
- djarea_clients.py → clients.py (both example apps)
- export_djarea_schema → export_mizan_schema (management command)
- djarea.spec.ts → mizan.spec.ts (playwright test)
- fetch.mjs: command name updated
- apps.py/asgi.py: import paths updated
- Removed stale generated.djarea.* artifacts
- Fixed desktop app: asgi.py import, vite config aliases, package.json dep path

373 Django tests pass. Both example apps verified running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:26:19 -04:00
1c6d9075ad Remove all Django-specific naming from mizan-react
Renamed:
  DjangoError         → MizanError
  DjangoHTTPClient    → MizanHTTPClient
  DjangoFormState     → MizanFormState
  DjangoFormsetState  → MizanFormsetState
  createDjangoCSRClient → createMizanCSRClient
  createDjangoSSRClient → createMizanSSRClient
  ensureDjangoSession → ensureMizanSession
  useDjangoCSRClient  → useMizanCSRClient
  TDjangoMessage      → TServerMessage

Made CSRF configurable:
  configureCsrf(cookieName, headerName) — defaults to Django
  conventions but works with any backend that uses CSRF tokens.
  Cookie name and header name are no longer hardcoded.

All old names preserved as deprecated aliases in index.ts exports
for backwards compatibility.

Removed dead RouterAdapter re-export (file moved to legacy/).

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:55:24 -04:00
27c30d7e50 Move allauth + auth UI to legacy/
allauth/ (44 files) is a django-allauth React UI — a separate concern
from the Mizan protocol. Moved to legacy/ pending extraction into a
standalone mizan-django-allauth package.

Also moved to legacy/:
- client/AuthContext.tsx — generic auth state from /me endpoint
- client/RouterContext.tsx — framework-agnostic router adapter
- client/routing.tsx — UserRoute/StaffRoute/AnonymousRoute guards
- client/nextjs.tsx — Next.js router adapter for auth

These are auth UI infrastructure, not Mizan protocol. The Mizan core
only needs JWT for auth header selection (jwt/ stays — MizanProvider
depends on useJWT() to decide between Bearer and session auth).

Cleaned up re-exports in client/react.ts and vitest aliases.

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:41:22 -04:00
24ff0ae66d Cleanup: delete dead code, fix invalidateFunctions bug, deduplicate
Deleted:
- runtime/index.ts (146 lines) — never imported by anything
- httpFunctionCall + _csrClient cache — redundant third HTTP path
- 3 duplicate getCSRFToken() implementations → shared utils.ts

Fixed:
- invalidateFunctions() was ignoring function names and invalidating
  ALL mounted contexts. Now correctly passes names through.

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:36:33 -04:00
1b5dca5ab3 SSR: file-path rendering, no component registry
The worker receives a file path in the JSON message, dynamically
imports it, renders it. No registerComponent API, no app entry file,
no export maps. Django's template backend resolves the template name
to an absolute path against DIRS, same as every other template engine.

  render(request, 'components/Hello.tsx', {'name': 'World'})

Verified working: curl http://localhost:8000/hello/ returns
  <div id="mizan-root"><div>Hello, World!</div></div>

Changes:
- worker.tsx: receives file path, dynamic import with cache
- bridge.py: sends file path instead of component name
- backend.py: resolves template name against DIRS to absolute path
- Fix bridge.py:147 bug (referenced deleted 'component' variable)
- Example app: Hello.tsx component, /hello/ view, template config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:33:01 -04:00
658cbebce1 Regenerate example code + fix broken paths from repo restructure
- Fix testapp/apps.py: import djarea_clients (file was never renamed)
- Fix fetch.mjs: command is export_djarea_schema not export_mizan_schema
- Fix harness package.json: dependency path to mizan-react after restructure
- Add package.json for generator (openapi-typescript dependency)
- Regenerate all example code with new protocol format:
  - generated.provider.tsx uses raw context responses + SSR hydration
  - generated.server.ts uses GET /ctx/global/ with response.ok check
  - generated.forms.ts, channels.ts, channels.hooks.tsx refreshed
- Remove stale generated.django.tsx and generated.django.server.ts
- Update imports: fixtures.tsx and main.tsx import from ./api (index)
- Use MizanContext instead of deprecated DjangoContext in examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:13:35 -04:00
711e92ac4d Fix protocol mismatch + add SSR hydration to codegen
Three bugs fixed:

1. MizanProvider.call() read data.data but server returns data.result.
   Now reads data.result and processes data.invalidate for server-driven
   invalidation (triggering refetch on mounted context providers).

2. GlobalContextLoader expected {error, data} wrapper but context GET
   returns raw bundled data. Fixed to iterate response directly.

3. Named context providers had same wrapper assumption. Fixed to
   setData(result) directly.

Two features added:

1. SSR hydration: GlobalContextLoader checks window.__MIZAN_SSR_DATA__
   on mount. If present, populates contexts from it and skips fetch.

2. SSR hydration: Named context providers check __MIZAN_SSR_DATA__ in
   useState initializer. If SSR data exists for their functions, they
   render immediately without fetching.

3. Server-driven invalidation in MizanProvider.call(): reads the
   invalidate array from mutation responses and triggers refetch on
   mounted providers. Generated mutation hooks' hardcoded invalidation
   is now redundant but idempotent — both paths coexist safely.

Also fixed FunctionSuccessResponse type to match new protocol:
  { result: T, invalidate?: [...] }

373 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:08:32 -04:00
c237a6379b Add CLAUDE.md — exhaustive technical reference for the codebase
Documents the three protocols (RPC, Invalidation-on-Mutation,
Frontend-Agnostic Rendering), the full @client decorator API surface
with all parameters and _meta structure, the HMAC cache key derivation
scheme, Redis/Memory backends, the MWT/JWT token systems with secret
separation, the SSR template backend + Bun worker bridge, the Edge
manifest format, and the current codegen gap.

Written from reading every source file, not from memory or prior docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:46:13 -04:00
4147679e6b Add SSR bridge: Django template backend + Bun subprocess renderer
Mizan's SSR is a Django template backend. Configure in TEMPLATES:

    TEMPLATES = [{
        'BACKEND': 'mizan.ssr.MizanTemplates',
        'OPTIONS': {'worker_path': 'frontend/ssr-worker.tsx'},
    }]

Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
component via a persistent Bun subprocess. The component name is the
template name. The context dict becomes props.

Architecture:
- Bun worker: stdin/stdout JSON-RPC, renderToString, component registry
- Django bridge: subprocess lifecycle, crash recovery, concurrent renders
- Template backend: implements Django's BaseEngine interface

This is the AFI's SSR boundary:
- Backend adapter implements mizan.ssr() (data gathering)
- Frontend adapter implements renderToHTML() (component rendering)
- Bun subprocess is the runtime hosting the frontend adapter

11 tests: ping, render, error handling, crash recovery, concurrent
renders (5 threads), template backend integration. All require Bun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:18:05 -04:00
e5f8fafc01 Remove CDN Cache-Control headers; fix cross-language sort bug
Mizan's protocol layers (origin Redis cache, Edge Worker) handle caching
autonomously. The origin emits Cache-Control: no-store on ALL responses —
browsers and non-Mizan intermediaries must not cache. The Edge Worker
controls CDN caching via cf object, independent of origin headers.

Also fixes:
- TS localeCompare → byte-order sort (localeCompare is locale-sensitive,
  would produce different HMAC keys for non-ASCII params vs Python)
- Python cache_purge: empty {} params no longer treated as falsy
  (was inconsistent with JS where {} is truthy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:38:24 -04:00
7f5542e305 Simplify cache: remove reverse indexes, use direct key reconstruction
The reverse index approach (Redis sets tracking HMAC keys per context)
was over-engineered. Scoped purge doesn't need an index — recompute
the HMAC key from the invalidation params and DELETE directly. One
Redis command, no TOCTOU race, no atomicity concern, no stale members.

Broad purge uses key-prefix scan (keys are now "ctx:{context}:{hmac}").
This is rare (Tier 3 fallback) and acceptable as a SCAN operation.

Eliminated from both Python and TypeScript:
- All SET/SADD/SMEMBERS/SREM index operations
- CacheBackend.get_index, remove_from_index, delete_index, delete_indexes_by_prefix
- build_index_keys function
- Pipeline transaction complexity
- TOCTOU race condition (was critical, now impossible)

Backend interface is now 5 methods: get, set, delete, delete_by_prefix, clear.
Redis tests updated — prefix isolation test added, connection leak fixed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:21:24 -04:00
dbbb269696 Add Redis backend tests against real Redis instance
13 tests hitting Redis on localhost:6399 (docker run redis:alpine):
- get/put/delete, index tracking, remove_from_index, delete_by_prefix
- TTL verification on cache entries AND index sets
- Pipeline atomicity (value + indexes written together)
- Scoped purge (AND semantics) against real Redis
- Broad purge with sub-index cleanup
- Tests skip gracefully if Redis is not available

No mocks, no fakes. Real Redis or skip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:10:35 -04:00
4744ff052e Add TypeScript cache adapter with cross-language conformance tests
Port of Python's origin-side cache to TypeScript:
- cache/keys.ts: deriveCacheKey with stableStringify for JSON-canonical HMAC
- cache/backend.ts: MemoryCache (same API as Python)
- cache/index.ts: cacheGet, cachePut, cachePurge with AND semantics

Integrated into dispatch.ts:
- handleContextFetch: cache lookup before execution, store after
- handleMutationCall: purge on invalidation

Cross-language pin test proves Python and TypeScript produce identical
HMAC-SHA256 output for the same inputs:
  Public:      605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6
  User-scoped: 30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2

34 TypeScript tests (9 new), 165 Python tests (1 new pin test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:03:24 -04:00
54581d184f Fix MWT security issues from expert review
Critical:
- Separate MIZAN_MWT_SECRET from MIZAN_CACHE_SECRET — compromising one
  no longer compromises the other (token forgery vs cache poisoning)
- Move kid from JWT payload to JOSE header per RFC 7515 — standard
  libraries use header kid for key selection before payload decode

High:
- Full SHA-256 pkey (64 chars) instead of truncated 16 — no reason to
  reduce collision resistance
- Add nbf (not-before) claim for clock skew protection
- Log warnings in _try_mwt_auth on missing secret and decode failures
  instead of silent swallow
- Rename _csrf_protect_unless_jwt to _csrf_protect_unless_token (accuracy)
- decode_mwt logs at DEBUG level on failures for observability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:52:30 -04:00
d7ec13c43c Add MWT (Mizan Web Token) — protocol-owned identity layer
MWT is a standard JWT with Mizan-specific claims on X-Mizan-Token header:
- sub: user_id for HMAC cache key derivation
- pkey: deterministic hash of user's permission state (staff + superuser + perms)
- kid: key ID for future secret rotation
- aud: audience binding for cross-tenant protection

Executor checks X-Mizan-Token first, falls back to Authorization: Bearer
for legacy JWT compat. Invalid tokens return 401 (no session fallback).

New: mizan/mwt.py (create_mwt, decode_mwt, MWTUser, compute_permission_key)
New: mwt_obtain server function for session-to-MWT issuance
New: MIZAN_MWT_TTL setting (default 300s = 5 min permission staleness window)
11 new tests covering creation, decode, pkey determinism, auth integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:41:18 -04:00
a2388b3ab2 Add rev and cache parameters to @client decorator
rev=N: bumped by developer when function logic changes. Becomes part of
the HMAC cache key — old cache entries are unreachable without purge.
Effective rev for a context is max(rev) across all functions in it.

cache=int|False|True: TTL escape hatch for unobservable mutations.
cache=60 emits s-maxage=60. cache=False emits no-store. Default (True)
emits s-maxage=31536000 (forever, purge on mutation).
Effective cache for a context is min(TTL) across functions, with False
taking precedence.

Both parameters flow through: decorator → meta → manifest → cache key
and Cache-Control headers. Implemented in both Python and TypeScript
with 13 Python tests and 4 TypeScript tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:20:32 -04:00
298 changed files with 9944 additions and 3979 deletions

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: django
working-directory: backends/mizan-django
steps:
- uses: actions/checkout@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: react
working-directory: frontends/mizan-react
steps:
- uses: actions/checkout@v4

View File

@@ -1,336 +0,0 @@
# Architecture Rework: Cache Keying & Invalidation
**Date:** 2026-04-06
**Source:** 8 independent expert reviews (Cloudflare, Enterprise Backend, Django, SaaS Founder, Next.js, React Query, Framework Authoring, Serverless Architecture)
---
## Status Key
- [x] Fixed
- [ ] **BUG** — broken in shipped code
- [ ] **DESIGN** — must resolve before implementing cache layer
- [ ] **SPEC** — needs specification before building
- [ ] **OPS** — operational gap for production readiness
- [ ] **DX** — developer experience issue
- [ ] **BUSINESS** — product/pricing concern
---
## Bugs in Shipped Code
### [x] BUG: `Vary: Authorization, Cookie` does nothing on Cloudflare
**Files:** `executor.py:787`, `dispatch.ts:77`, `edge-compat.test.ts:75-79`
Cloudflare ignores all Vary values except `Accept-Encoding` and `Accept` (images only). This header creates a false sense of security — someone reading the code assumes different Authorization headers produce different cache entries. They do not. The edge-compat tests assert the presence of this non-functional header, reinforcing the illusion.
**Origin:** Claude hallucination in a prior session. Not a design decision.
**Fix:** Remove the header from both Python and TypeScript. Remove test assertions. Add a code comment explaining why Vary is not used and pointing to the HMAC cache key strategy (when implemented).
### [x] BUG: `fn` vs `function` wire protocol key mismatch
**Files:** `executor.py:619`, `runtime/index.ts:128`
The Django executor reads `body.get("fn")`. The TypeScript runtime sends `{ function: functionName }`. These don't match. Would break on first real use of the new TS runtime against the Django backend.
**Fix:** Align on one key name. Whichever is chosen, document it as stable wire format.
### [x] BUG: `max-age=0` defeats the PSR caching model
**File:** `executor.py:786`
`Cache-Control: public, max-age=0, stale-while-revalidate=300` means the origin gets hit on every request for background revalidation. This conflicts with PSR's purge-based freshness model, where content should be cached until explicitly invalidated.
**Fix:** For PSR-eligible contexts, emit `Cache-Control: public, s-maxage=31536000`. The CDN caches forever; purge is the only freshness mechanism. Reserve `max-age=0, stale-while-revalidate` for contexts that opt out of PSR or use time-based revalidation.
---
## Critical Design Flaws
### [ ] DESIGN: HMAC concatenation without delimiter
**Severity:** Security vulnerability — cache key collisions across different logical entries
`HMAC(secret, context + user_id + params)` without structured separation means `"user" + "12" + "3"` collides with `"user1" + "2" + "3"`.
**Fix:** Use null-byte delimiters: `HMAC(secret, context + "\x00" + user_id + "\x00" + canonical_sorted_params)`. Or HMAC over a JSON-canonical form. Document the canonical form as part of the AFI protocol spec.
### [ ] DESIGN: Full context flush on deploy = thundering herd
**Severity:** Operational — self-inflicted DDoS on every deploy that changes a decorator
Every deploy that changes any `@client` decorator nukes all cached content for affected contexts. Teams deploying 3-5x/day means the Edge cache is cold 3-5x/day. 100K concurrent users + 10 contexts = 1M origin requests in seconds post-deploy.
**Preferred fix:** Versioned cache keys. Include a manifest content hash in the cache key. Old and new entries coexist during transition. No purge, no thundering herd. 2x cache storage during transition (negligible). Old entries expire naturally via TTL or LRU eviction.
**Alternative fix:** Granular per-context diffing. Only flush contexts whose function signatures, params, or auth requirements actually changed. The manifest already contains per-context param lists to support this.
### [ ] DESIGN: Purge token in customer Workers exposes shared cache
**Severity:** Security — one compromised customer can purge all customers' cache
Every customer Edge Worker deployment carries a Cloudflare API token with `Zone:Cache Purge` permission for `render.mizan.cloud`.
**Fix:** Build a purge proxy Worker on the Mizan zone. Validates purge requests (HMAC signature + customer-scoped URL pattern matching) before forwarding to the Cloudflare purge API. No customer Worker ever holds a direct zone API token.
### [ ] DESIGN: Permission key race condition
**Severity:** Data correctness — stale content served for duration of JWT lifetime
User permission changes (e.g., tier upgrade) don't take effect until JWT expires because: (1) cache key uses only `user_id`, not tier, and (2) permission key comparison uses the JWT-derived value, which is stale until refresh.
**Options:**
- (a) Make permission-relevant attributes part of the cache key (increases cardinality).
- (b) Accept the JWT-lifetime staleness window, document as known constraint.
- (c) Add short-TTL revalidation for permission-sensitive contexts.
**Decision needed before implementation.**
### [ ] DESIGN: No `waitUntil()` in purge/warm flow
**Severity:** Latency — client blocks on cache management operations
If a mutation invalidates N URLs, the Edge Worker must complete all purge API calls before responding. Each call is 50-200ms.
**Fix:** Return mutation response immediately. Fire all purge and warming fetches inside `waitUntil()`. Same Worker invocation, no extra billing, client doesn't block.
---
## Missing Specifications
### [ ] SPEC: Secret rotation protocol
No rotation mechanism, no dual-secret acceptance window, no compromise recovery procedure. Rotating the single secret invalidates every HMAC globally.
**Need:** Key derivation hierarchy (master secret -> per-context derived keys). Rotation at context level. Dual-secret acceptance window during rotation. Document compromise recovery procedure.
### [ ] SPEC: GDPR right-to-erasure for cached content
HMAC keys make targeted per-user cache purge difficult. Must reconstruct every possible HMAC for every context x param combination for a given user.
**Need:** `purge_by_user(user_id)` operation that iterates manifest contexts to reconstruct all HMACs. Tractable if context count is bounded. Audit trail for compliance proof.
### [ ] SPEC: Cache adapter conformance requirements
Every Mizan backend adapter (Python, TypeScript, and future: PHP, C#, Go, etc.) must
implement the origin-side cache protocol. This is NOT a binary ABI or pluggable backend
interface. It is a set of operations each adapter implements in its own language, backed
by Redis. Conformance is verified by a shared test suite (same model as the existing
edge-compat tests that prove Python and TypeScript produce identical protocol output).
**Storage:** Redis. Not pluggable. Not in-memory-only. Redis handles persistence,
cross-worker sharing, and crash recovery. The adapter is a thin protocol layer over
Redis commands.
**Required operations:**
```
cache_get(context: string, params: dict, user_id: string | null, rev: int) -> CachedResponse | null
```
Derives HMAC key from inputs using JSON-canonical form, fetches from Redis.
```
cache_put(context: string, params: dict, user_id: string | null, rev: int, response: CachedResponse) -> void
```
Derives HMAC key, stores response in Redis. Also maintains a reverse index
(context + params -> HMAC keys) so `cache_purge` can find entries to delete.
```
cache_purge(context: string, params: dict | null) -> int
```
Looks up the reverse index for matching entries, deletes them from Redis.
Returns number of entries purged. When `params` is null, purges entire context.
```
cache_purge_user(user_id: string) -> int
```
Iterates all contexts in the manifest, reconstructs HMAC keys for the given
user_id across all param combinations in the reverse index, deletes them.
Required for GDPR right-to-erasure.
**HMAC key derivation (must be identical across all adapters):**
```
key = HMAC-SHA256(secret, JSON.stringify({
"c": context,
"p": sorted_params,
"r": rev,
"u": user_id // omitted for public content
}, sort_keys=True))
```
**MWT validation (must be identical across all adapters):**
Validate the `X-Mizan-Token` header as a standard JWT (HMAC-SHA256). Extract `sub`
(user_id) for cache key derivation, check `exp` for token freshness.
**Conformance test suite:**
Each adapter must pass a shared set of protocol conformance tests verifying:
- Identical HMAC output for identical inputs (cross-language determinism)
- Identical MWT validation behavior
- Correct purge semantics (scoped and broad)
- Correct reverse index maintenance
- Correct `cache_purge_user` behavior
### [ ] SPEC: Client-side cache lifecycle
Runtime is ~95 lines. No `staleTime`, `isFetching`/`isLoading` distinction, garbage collection, retry logic, optimistic updates, `refetchOnWindowFocus`.
**Minimum viable:**
- Loading/fetching state distinction (don't throw on missing data)
- Error return shape: `{ data, isLoading, isFetching, error }`
- `refetchOnWindowFocus` as default
- Mutation lifecycle with rollback support for optimistic updates
- Garbage collection for unmounted context data (configurable delay)
### [x] SPEC: Per-context cache policy
`cache=` on `@client` accepts three forms:
- **Omitted (default):** Invalidation-based. Emits `s-maxage=31536000`. Cache forever,
purge on mutation. Use when your backend is the source of truth.
- **`cache=60` (integer seconds):** TTL-based. Emits `s-maxage=60`. Accept bounded
staleness. Use for unobservable mutations — when your backend mirrors external data
(third-party APIs, aggregations, upstream services) and cannot know when it changes.
- **`cache=False`:** Never cache. Emits `Cache-Control: no-store`. Use for
non-deterministic functions (`random()`, `datetime.now()`).
This is the escape hatch for data the backend doesn't own the mutation scope for.
Positioned in docs as: "Are you the source of truth, or a mirror? Source of truth →
use `affects=`. Mirror → use `cache=N`."
The `cache=int` value flows into the edge manifest per-context, so the Edge Worker
and CDN respect it without special handling (`s-maxage` is standard CDN behavior).
### [ ] SPEC: Extension points for cache/invalidation lifecycle
Zero hooks for third-party code. No pre-invalidation hook, no custom cache key function, no invalidation transport plugin.
**Minimum viable:**
- `CacheBackend` protocol (third parties implement custom backends)
- `on_invalidate(context, params)` event hook (monitoring/debugging)
- Document these as public API from day one
### [x] SPEC: Manifest versioning
The manifest has no version field. When the schema evolves, Edge Workers can't distinguish v1 from v2 format.
**Fix:** Add `"version": 1` to manifest root before anyone deploys it. Edge Workers check version and fail fast on unknown versions.
### [x] SPEC: Wire format convention
Python emits `snake_case` params (`user_id`). TypeScript conventionally uses `camelCase` (`userId`). The `USER_SCOPED_PARAMS` set in `manifest.ts` contains both conventions. Invalidation headers from Python won't match TypeScript keys expecting `camelCase`.
**Fix:** Document `snake_case` as the wire format convention. TypeScript adapters convert at the boundary.
---
## Operational Gaps
### [ ] OPS: No cache observability
No hit/miss metrics, no cache key debugging, no invalidation audit trail, no manifest version tracking.
**Need:** `X-Mizan-Cache-Status` response header (HIT/MISS/BYPASS/STALE/PURGED/DYNAMIC). Structured logging in Edge Worker. Console-level invalidation event log for devtools.
### [ ] OPS: Purge rate limits at scale
Cloudflare zone purge API: 500 req/10s (free/pro), 2500/10s (Enterprise). Bulk operations can exceed this.
**Need:** Batch purge requests (up to 30 URLs per API call). Document rate limits. Design Cache Tags upgrade path for Enterprise.
### [ ] OPS: Purge-then-warm race condition
Warming fetch arriving at a PoP before purge propagates gets a cache HIT on stale data.
**Fix:** Use `Cache-Control: no-cache` or `cf: { cacheTtl: 0 }` on warming requests to force revalidation.
### [ ] OPS: PSR warming only warms one colo
Warming fetch from a Worker runs in a single datacenter. Only warms that colo's cache (+ upper-tier if Tiered Cache active). Does not warm all 300+ PoPs.
**Document:** PSR warming reduces origin load by warming the shield tier. First request from each edge PoP is still a cache miss to the shield. Not zero-latency for all users.
---
## Django Integration Concerns
### [ ] DX: `@client` breaks decorator stacking
`@client` returns a class (`FunctionWrapper`), not a callable. `@login_required`, `@csrf_exempt`, `@cache_page` cannot compose with it.
**Options:**
- (a) Make `@client` return a wraps-compatible callable that also carries metadata (Django Ninja approach).
- (b) Document incompatibility prominently. Provide Mizan-native equivalents. State that `@client` replaces `@login_required` (via `auth=`), `@cache_page` (via context caching), etc.
### [ ] DX: `JWTUser` too thin for complex auth checks
Works for `is_staff`/`is_superuser`. Fails for allauth relations, DRF permissions, `request.user.groups.all()`, user model relations.
**Need:** Document limitation. Provide `get_full_user()` helper that does DB lookup when needed. Or optionally expand JWT claims.
### [ ] DX: Transaction safety of invalidation
Invalidation in response body is optimistic — fires before `ATOMIC_REQUESTS` commits. If transaction rolls back, invalidation was already sent.
**Need:** Document as known behavior. Recommend `transaction.on_commit()` for critical paths. When building `mizan-cache`, consider two-phase: mark for invalidation during request, execute purge on commit.
### [ ] DX: Admin/ORM writes invisible to invalidation
Only `@client(affects=...)` functions trigger invalidation. Django admin saves, management commands, direct ORM writes are invisible.
**Need:** Document clearly. Provide manual purge API: `purge_context('products', params={'product_id': 42})`.
### [ ] DX: Cache adapter integration for Django
The Python cache adapter is a thin protocol layer over Redis (not a Django cache backend).
Django developers call `mizan.cache.get(context, params, user_id, rev)` directly.
Provide a `mizan.cache.clear()` for test fixture teardown. Document that this is
separate from Django's `CACHES` framework — Mizan owns its own cache protocol.
---
## Business/Product Concerns
### [ ] BUSINESS: Free tier + Cloudflare free = 80% of paid product
Existing `Cache-Control` headers on context fetches are CDN-ready. A developer puts Cloudflare free tier in front and gets stale-while-revalidate at 300+ PoPs for $0. The 20% gap (user-scoped HMAC keying, PSR, render Workers) doesn't exist in code yet.
### [ ] BUSINESS: $20/seat wrong pricing model
"Seat" is undefined for a framework. Usage-based ($0.50/100K requests with generous free tier) or flat-per-project ($29/month) converts better for infrastructure products.
### [ ] BUSINESS: Ship framework first, cloud second
The framework has working code. The cloud product has zero. Risk: building both depletes runway before either has adoption. Recommended: get 500 devs using `@client` + `affects=` on their VPS first, then build the Edge product for the gap they actually hit.
---
## Validated Design Decisions (No Changes Needed)
These were confirmed sound by multiple reviewers:
- **Declarative invalidation graph** (`affects=` + auto-scoping) — unanimously praised as genuinely novel
- **Two-zone `fetch()` pattern** — correct architecture for global CDN caching from Workers
- **Cross-language protocol** — Python/TS with identical manifests, proven by parallel test suites
- **Manifest-driven URL resolution** — eliminates need for cache inventory state (no KV/DOs needed)
- **Typed `ReactContext` for `affects` targeting** — prevents the string-fragility concern (string form is escape hatch only)
- **Replacing React Query** — correct decision given context bundling + transport transparency goals
- **Cost model** — ~$5/month Cloudflare at 10K DAU, ~$20/month at 10x. Origin infra is the real cost.
- **Origin-side Redis cache as L2** — viable fallback behind CDN, same protocol as Edge
---
## Unique Expert Insights
**Cloudflare Expert:**
- Add `cf.cacheTtl` and `cf.cacheEverything` to all `fetch()` subrequests — don't rely solely on response headers
- Consider Cache Tags (`Cache-Tag` response header) from day one for Enterprise upgrade path
- Consider Durable Objects for per-user cache coordination as alternative to HMAC-in-URL
**Enterprise Architect:**
- Key derivation hierarchy: master secret derives per-context keys. Compromise of one context doesn't affect others.
- `X-Mizan-Cache-Version` header on every response for self-healing on version mismatch
**Serverless Expert:**
- Use `renderToReadableStream` (streaming SSR) in Render Worker, not `renderToString`. Memory and CPU budget are tight (128MB / 50ms).
- Cache manifest in `globalThis` in Edge Worker — do not read from KV per-request
- AWS portability: CloudFront invalidation pricing is 10-100x more expensive. Design TTL-based alternative.
**Next.js Expert:**
- PSR doesn't address cold-start pages (initial population before any mutation) or render fan-out (10K parameterized variants re-rendering on one mutation)
- No streaming/Suspense/progressive delivery — entire context response blocks on slowest function
**React Query Expert:**
- Wire existing WebSocket push infrastructure to emit invalidation events for named contexts
- Generated hooks should return `{ data, isLoading, isFetching, error }`, not throw on missing data
**Django Architect:**
- DRF `TokenAuthentication` collision: both use `Authorization: Bearer`, Mizan's JWT decode rejects DRF tokens with a 401
- `mizan-cache` as Django cache backend, not separate system
**Framework Authoring:**
- Define `CacheBackend` protocol before implementing — the abstraction is cheaper to get right before users exist
- Add `"version": 1` to manifest root now — adding it later is harder
- `@client` is approaching parameter overload — if `cache` becomes extensible, use `CachePolicy` object pattern, not more kwargs
**SaaS Founder:**
- The debugging UX for HMAC cache is a black box — invest in an invalidation graph debugging UI as a paid feature
- The `affects=` auto-refetch is the "wow" moment — optimize time-to-that-moment in onboarding

467
CLAUDE.md Normal file
View File

@@ -0,0 +1,467 @@
# 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)
frontends/ client kernel + per-framework adapters
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
mizan-svelte/ Svelte stores/runes over the kernel
cores/ shared language-level primitives
mizan-python/ @client decorator, registry, MWT, HMAC cache keys; consumed by both Python backends
protocol/ protocol-level tooling
mizan-generate/ codegen — fetches schema from any backend, emits typed React/Vue/Svelte client
workers/ runtime workers / bridges
mizan-ssr/ Bun subprocess used by the Django template backend
```
---
## The Three Protocols
### 1. RPC Protocol (Anti-REST)
No resources. No CRUD. Functions in, results out.
**Context fetch (reads):**
```
GET /api/mizan/ctx/<context_name>/?param1=val1&param2=val2
200 OK
Cache-Control: no-store
Content-Type: application/json
{
"user_profile": {"name": "Ryth", "email": "ryth@example.com"},
"user_orders": [{"id": 1, "total": 100}]
}
```
All functions sharing a context name are bundled into one response. Keys are function names. Values are return values.
**Mutation call (writes):**
```
POST /api/mizan/call/
Content-Type: application/json
{"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}
200 OK
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5
{
"result": {"ok": true},
"invalidate": [{"context": "user", "params": {"user_id": 5}}]
}
```
### 2. Invalidation-on-Mutation Protocol
Two transports for the same signal. Both are first-class.
**Transport 1 — JSON body** (for RPC/SPA clients):
```json
{"result": {...}, "invalidate": ["user"]}
{"result": {...}, "invalidate": [{"context": "user", "params": {"user_id": 5}}]}
```
**Transport 2 — HTTP header** (for Edge, htmx, view-path functions):
```
X-Mizan-Invalidate: user
X-Mizan-Invalidate: user;user_id=5
X-Mizan-Invalidate: user;user_id=5, notifications
```
Format: comma-separated contexts, semicolon-separated URL-encoded params per context.
**Three-tier auto-scoping** (no developer annotation needed):
1. **Argument name matching:** mutation has `user_id` param, context has `user_id` param → scoped automatically
2. **Auth inference:** Edge-side concern (reads JWT/MWT to extract user identity)
3. **Broad fallback:** invalidate all instances of the context
**Return-type branching** determines which transport:
- Function returns data (dict, BaseModel) → RPC path → JSON body + header
- Function returns HttpResponse (redirect, HTML) → View path → header only
### 3. Frontend-Agnostic Rendering (SSR + PSR)
**SSR** — Django template backend integration. `render(request, 'ProfilePage', props)` calls a persistent Bun subprocess that runs `renderToString`.
**PSR** (Preemptive Static Rendering) — pages re-rendered on mutation, not on request. Edge caches the result. Controlled by the manifest's `render_strategy` field.
**The Bun worker protocol** — JSON-RPC over stdin/stdout:
```
→ {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {"userId": 5}}}
← {"id": 1, "html": "<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',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
```
### Usage in Views
```python
from django.shortcuts import render
def profile_page(request, user_id):
profile = get_user_profile(user_id)
return render(request, 'ProfilePage', {'profile': profile})
```
`render()` calls `MizanTemplates.get_template('ProfilePage')` which returns a `MizanTemplate`. The template's `render(context)` sends JSON-RPC to the Bun worker.
### SSR Bridge (bridge.py)
- Spawns `bun run <worker_path>` on first render
- Persistent subprocess — stays alive across requests
- JSON-RPC over stdin/stdout with message ID correlation
- Thread-safe: multiple Django workers can call `render()` concurrently
- Auto-restarts on crash
- Waits for `{"id": 0, "ready": true}` before accepting requests
### Bun Worker (worker.tsx)
- Reads newline-delimited JSON from stdin
- Component registry: `registerComponent('ProfilePage', ProfilePage)`
- Calls `renderToString(createElement(Component, props))`
- Returns `{"id": N, "html": "..."}` or `{"id": N, "error": "..."}`
- Health check: `{"method": "ping"}``{"pong": true}`
---
## Edge Manifest
Generated by `generate_edge_manifest()` or `python manage.py export_edge_manifest`.
```json
{
"contexts": {
"user": {
"functions": [
{"name": "user_profile", "path": "rpc"},
{"name": "profile_page", "path": "view", "route": "/profile/<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 `protocol/mizan-generate/` — framework-agnostic, two-stage. Stage 1 emits the protocol layer (`callXxx` for mutations, `fetchXxx` for context bundles, types). Stage 2 emits per-framework hooks/composables/stores that subscribe to the `mizan-base` kernel.
**What's in place:**
- Function hooks (`useEcho`, `useUserProfile`, etc.) in the React adapter, subscribing to kernel state via `useSyncExternalStore`
- Context hooks for named contexts and `global`
- Channel hooks for WebSocket transport
- Vue and Svelte equivalents (Stage 2 templates compile, but no live-backend example exercises them — see `ISSUES.md` A4)
**What's not yet emitted (the wrapper layer):**
- `<MizanContext>` provider component for React (calls `configure()` and mounts the kernel into the component tree)
- `useMizan()` hook for accessing the kernel from React
- Framework-named error class (e.g. `DjangoError`) wrapping `MizanError` from the kernel
- Vue and Svelte equivalents
The legacy `MizanProvider` in `mizan-react/src/context.tsx` (~750 lines) and the `forms.ts` consumer (~1163 lines) still depend on the pre-kernel API. They're tracked as `ISSUES.md` A1 and A3. Removing them is gated on the wrapper layer above being emitted.
The SSR pipeline is independent of the codegen — it renders whatever components are registered in the Bun worker.

173
ISSUES.md Normal file
View File

@@ -0,0 +1,173 @@
# Mizan — Known Issues
Identified by domain expert review (Cloudflare, Serverless, Vercel, React Query, Django, Laravel, Vue/Svelte).
## Fixed
- ~~C1~~ Scoped cache purge now passes user_id
- ~~C2~~ initSession retries 3x, resets on failure
- ~~C3~~ SSR backend injects `__MIZAN_SSR_DATA__` script tag
- ~~C4~~ SSR bridge uses _write_lock for stdin
- ~~C5~~ SSR bridge registers atexit handler
- ~~C7~~ View-path mutations now purge origin cache
- ~~H1~~ pendingScoped is Array, not Map (no overwrite)
- ~~H2~~ stableKey() sorts JSON keys (order-independent)
- ~~H3~~ mizanFetch retries 2x on 5xx/network errors
- ~~H4~~ Named contexts skip refetch if SSR data exists
- ~~H6~~ refreshContext uses GET /ctx/ not POST /call/
- ~~H10~~ _meta always fresh dict
- ~~H11~~ Python normalizes True→"true" for cross-language HMAC
- ~~H13~~ isValid checks all required fields are touched
- ~~M11~~ execute_function return type includes HttpResponseBase
- ~~M18~~ registerContext cleanup uses ?. (no crash)
## Remaining Critical
### C6. No loading/error/stale states in runtime
**File:** `mizan-base/src/index.ts`
The kernel stores only `{params, refetch}`. No `data`, `status`, `error`. Every adapter reinvents loading tracking. Blocks stale-while-revalidate.
## Remaining High
### H5. Mutation hooks expose no loading/error state
**File:** `protocol/mizan-generate/generator/lib/adapters/react.mjs`
Returns bare `useCallback`. No `isPending`, `error`, `isSuccess`.
### H7. Redis SCAN blocks request path at scale
**File:** `mizan-django/src/mizan/cache/backend.py`
Synchronous SCAN at 1M keys: multi-second blocking.
### H8. Svelte codegen uses Svelte 4 stores
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
Should use Svelte 5 `$state`/`$derived` runes.
### H9. Svelte destroy() not auto-called
**File:** `protocol/mizan-generate/generator/lib/adapters/svelte.mjs`
Memory leak if user forgets `onDestroy`.
### H12. Forms triggerValidation captures stale data
**File:** `mizan-react/src/forms.ts`
Debounced validation uses stale closure data.
## Remaining Medium
### M1. SSR bridge not fork-safe
gunicorn prefork shares file descriptors and Redis connections.
### M2. cache_purge_user() not implemented
No way to purge all cache entries for one user.
### M3. No garbage collection for context entries
Runtime `contexts` Map grows monotonically.
### M4. No cross-tab invalidation
No BroadcastChannel. Logout in tab 1 doesn't affect tab 2.
### M5. React 18 Strict Mode double-fetch
useEffect runs twice in dev mode.
### M6. No request deduplication
Two components mounting same context fire parallel fetches.
### M7. SSR worker module cache never invalidates
Dynamic imports cached forever.
### M8. Vue injection key not exported
Can't inject directly without generated composables.
### M9. Vue onMounted won't pre-fetch in Vue SSR
Needs `onServerPrefetch` for Nuxt.
### M10. Svelte should use setContext/getContext
Module-level stores don't scope to component tree.
### M12. render_strategy heuristic uses hardcoded param names
Misses `member_id`, `customer_id`, non-English names.
### M13. initSession called for token-auth requests
Wastes GET /session/ round-trip for JWT/MWT apps.
### M14. Vue watch imported but unused
Params not watched — reactive param changes don't trigger refetch.
### M15. Vue mutation composables misleading `use` prefix
`export const useXxx = callXxx` — not a real composable.
### M16. Svelte mutation imports bypass Stage 1 index
Should import from `'../index'` consistently.
### M17. Side effects in React state updater
Context listeners called inside `setContextStore()` updater.
## Architectural / Cleanup Debt
### A1. Legacy MizanProvider not yet removed
**File:** `mizan-react/src/context.tsx` (~750 lines)
Superseded by the kernel (`mizan-base`) + generated React adapter (`useSyncExternalStore`). Still exported as `MizanProvider`, `useMizan`, `useMizanContext`, etc. Must be deleted or replaced with thin shims that call `configure()` + delegate to the new generated hooks.
### A2. Allauth pending extraction
**File:** `legacy/allauth/` (44 files)
Sitting in `legacy/` since the cleanup pass. Should become its own `mizan-django-allauth` package consuming Mizan's public API. Unblocks v1 mizan-react publishing.
### A3. Forms codegen not adapted to kernel
**File:** `mizan-react/src/forms.ts` (~1163 lines)
Still uses `useMizan().call()` from the legacy MizanProvider. Needs rewrite to use `mizanCall` from the kernel. Currently the only consumer of MizanProvider — blocks A1.
### A4. Codegen for Vue/Svelte not validated end-to-end
The Stage 2 templates produce code that compiles, but no example app exercises Vue or Svelte rendering against a live backend. React is the only adapter with full integration verification.
### A5. ROADMAP.md is stale
**File:** `ROADMAP.md`
Lists SSR Bridge, Edge Manifest, Codegen Rewrite, etc. as "Next" — all are done. Doesn't reflect:
- Two-stage codegen with Vue/Svelte adapters
- C6 kernel-owned state (`ContextState<T>`)
- mizan-ts cross-language adapter
- Cleanup of djarea/Django-specific naming
### A6. CLAUDE.md may also be stale
**File:** `CLAUDE.md`
Written before the kernel rewrite. References to MizanProvider responsibilities and the old codegen pattern are likely outdated. Needs audit.
## Test Coverage Gaps
### T1. No tests for C6 kernel state machine
**File:** `mizan-base/` has no `tests/` directory at all
The state-owning kernel has zero unit tests. No coverage of:
- `registerContext` returning `getState/subscribe/refetch/unregister`
- Status transitions: idle → loading → success/error
- Subscriber notifications on state change
- Refetch reusing the same entry on Strict Mode re-mount
- `unregister` clearing listeners
### T2. No tests for generated Vue adapter output
The `vue.mjs` template produces code, but no test verifies it generates valid Vue 3 composables, that `onServerPrefetch` is wired correctly, or that the kernel subscription bridges to Vue reactivity.
### T3. No tests for generated Svelte adapter output
Same as T2. Readable store factory pattern is unverified against actual Svelte components.
### T4. No tests for view-path cache purge (C7 fix unverified)
The fix added `_purge_cache_for_invalidation()` to the view-path branch, but no test asserts that an `HttpResponse`-returning mutation actually purges the origin cache.
### T5. No tests for SSR thread safety (C4 fix unverified)
The `_write_lock` was added but no concurrent-render test exists to prove it prevents JSON interleaving.
### T6. No tests for SSR atexit cleanup (C5 fix unverified)
`atexit.register(self.shutdown)` was added but not exercised — no test that asserts the Bun process is reaped on Python exit.
### T7. No tests for SSR hydration injection (C3 fix unverified)
The `<script>window.__MIZAN_SSR_DATA__=...</script>` was added to template output but no test asserts it appears in rendered HTML or that the JSON is valid/safe.
### T8. No cross-language HMAC pin test for booleans/None (H11 fix unverified)
Python now normalizes True→"true", but there's no test comparing Python's `derive_cache_key(secret, ctx, {flag: True})` against TypeScript's equivalent to prove they produce identical hex output.
### T9. No tests for retry logic (H3)
`fetchWithRetry` retries 5xx/network errors with backoff. No test for: 5xx triggers retry, 4xx does not, mutation calls bypass retry, max retries respected.
### T10. No end-to-end integration test
Nothing exercises the full pipeline: Django function defined → schema exported → codegen runs → generated React mounts → mutation fires → server response includes invalidate → kernel refetches → DOM updates. Each layer is tested in isolation.
### T11. No tests for `isValid` requiring all required fields touched (H13 fix unverified)
The forms fix checks `field.required && !touched` but no test exercises a form with untouched required fields to confirm `isValid === false`.
### T12. No tests for `_meta` fresh-dict isolation (H10 fix unverified)
The shared-dict fix replaced `{**FunctionWrapper._meta, **meta}` with `{**meta}`. No test confirms that mutating one function's `_meta` doesn't leak into others.

View File

@@ -1,24 +1,40 @@
.PHONY: install test test-django test-react test-integration docker-up docker-down clean
.PHONY: install test test-core test-django test-fastapi test-react test-afi test-integration docker-up docker-down clean
DJANGO = packages/mizan-django
REACT = packages/mizan-react
CORE = cores/mizan-python
DJANGO = backends/mizan-django
FASTAPI = backends/mizan-fastapi
REACT = frontends/mizan-react
AFI = tests/afi
# ─── Setup ───────────────────────────────────────────────────────────────────
install:
cd $(CORE) && uv pip install -e .
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
cd $(FASTAPI) && uv pip install -e ".[dev]"
cd $(REACT) && npm install
# ─── Unit Tests ──────────────────────────────────────────────────────────────
test: test-django test-react
test: test-core test-django test-fastapi test-react test-afi
test-core:
cd $(CORE) && uv run --extra dev pytest
test-django:
cd $(DJANGO) && uv run pytest
test-fastapi:
cd $(FASTAPI) && uv run pytest
test-react:
cd $(REACT) && npm test
# AFI conformance — verifies mizan-django and mizan-fastapi emit equivalent
# schemas for the same @client fixture. Substrate-level gate, not e2e.
test-afi:
cd $(AFI) && uv run pytest
# ─── Integration Tests ──────────────────────────────────────────────────────
test-integration: docker-up

297
README.md
View File

@@ -1,297 +0,0 @@
# mizan
Django + React server functions framework. RPC, not REST.
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
```python
# Django
@client(context='global')
def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
```
```tsx
// React (generated)
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
```
## Packages
| Package | Path | Install |
|---------|------|---------|
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
## Quick Start
### 1. Django setup
```python
# settings.py
INSTALLED_APPS = [
"mizan",
"myapp",
]
# urls.py
from django.urls import include, path
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]
# asgi.py (for WebSocket support)
from mizan import wrap_asgi
from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application())
```
### 2. Define server functions
```python
# myapp/mizan_clients.py
from django.http import HttpRequest
from mizan.client import client
from mizan.setup.registry import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
### 3. Register in apps.py
```python
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
import myapp.mizan_clients # noqa: F401
```
### 4. Generate TypeScript
```bash
# django.config.mjs
export default {
source: {
django: {
managePath: '../backend/manage.py',
command: ['uv', 'run', 'python'],
},
},
output: 'src/api/generated.ts',
}
```
```bash
npx mizan-generate
```
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
### 5. Use in React
```tsx
// layout.tsx
import { DjangoContext } from '@/api'
export default function Layout({ children }) {
return <DjangoContext>{children}</DjangoContext>
}
```
```tsx
// page.tsx
import { useEcho, useCurrentUser, DjangoError } from '@/api'
function MyComponent() {
const user = useCurrentUser()
const echo = useEcho()
const handleClick = async () => {
try {
const result = await echo({ text: 'hello' })
console.log(result.message) // typed
} catch (e) {
if (e instanceof DjangoError) {
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
}
}
}
}
```
## Features
| Backend | Frontend (generated) | Transport |
|---------|---------------------|-----------|
| `@client` | `useXxx()` | HTTP |
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
| `@client(context='local')` | `useXxx()` with params | HTTP |
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket |
| `@compose(...)` | Combined providers | varies |
## Architecture
```
React app
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
├─ useEcho() ← generated function hook
├─ useContactForm() ← generated form hook (Zod + server validation)
└─ useChatChannel() ← generated channel hook (WebSocket)
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
Django executor
├─ Pydantic input validation
├─ Auth check (session, JWT, or custom)
├─ Function execution
└─ Pydantic output serialization
```
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
## Code Generation
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
| File | Contents |
|------|----------|
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
| `generated.channels.ts` | Channel message types |
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
| `index.ts` | Consolidated re-exports |
## Error Handling
All errors from server functions are thrown as `DjangoError`:
```tsx
try {
await echo({ text: 'hello' })
} catch (e) {
if (e instanceof DjangoError) {
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
e.message // Human-readable message
e.details // Field-level validation errors, etc.
e.isAuthError()
e.isValidationError()
e.getFieldErrors('email')
}
}
```
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
## Forms
Django forms get typed React hooks with client-side Zod validation:
```python
# Django
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact",
title="Contact Us",
submit_label="Send",
live_validation=True,
)
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
def on_submit_success(self, request):
send_email(self.cleaned_data)
return {"sent": True}
```
```tsx
// React (generated)
const form = useContactForm()
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } }
```
## Channels
WebSocket channels with typed messages:
```python
# Django
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
class ReactMessage(BaseModel):
text: str
class DjangoMessage(BaseModel):
text: str
user: str
def authorize(self, params):
return self.user.is_authenticated
def group(self, params):
return f"chat_{params.room}"
def receive(self, params, msg):
return self.DjangoMessage(text=msg.text, user=self.user.email)
```
```tsx
// React (generated)
const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected'
chat.messages // ChatDjangoMessage[]
chat.send({ text: 'hello' })
```
## Testing
```bash
# Django unit tests
cd packages/mizan-django && uv sync --extra dev --extra channels && uv run pytest
# React unit tests
cd packages/mizan-react && npm test
# E2E integration tests (real browser, real backend)
docker compose -f examples/django-react-site/docker-compose.test.yml up -d
cd examples/django-react-site/harness && npm install && npx mizan-generate && npx vite --port 5174 &
npx playwright test
# All at once
make test-all
```
## Project Structure
```
mizan/
packages/
mizan-runtime/ Client state engine (~150 lines, framework-agnostic)
mizan-django/ Django server adapter (decorators, dispatch, contexts, SSR)
mizan-react/ React adapter (thin wrapper around runtime)
examples/
django-react-site/ E2E tests + Django backend
django-react-desktop-app/ PyWebView desktop app
```

View File

@@ -1,88 +1,50 @@
# Mizan Roadmap
## v1 — Django + React
## v1 — Django + Multi-Framework (React, Vue, Svelte)
### Done
- **@client decorator** — `context=`, `affects=`, `auth=`, `websocket=`
- **ReactContext class** — type-safe context/affects references with linting
- **Named contexts** — functions sharing a context name are grouped into one provider and one fetch
- **`@client` decorator** — `context=`, `affects=`, `auth=`, `websocket=`, `private=`, `route=`, `methods=`, `rev=`, `cache=`
- **`ReactContext` class** — type-safe context/affects references with linting
- **Named contexts** — functions sharing a context name grouped into one provider and one fetch
- **Context bundling endpoint** — `GET /api/mizan/ctx/<name>/` returns all functions in one response
- **Server-driven invalidation (JSON body)** — mutation responses carry `{"result": ..., "invalidate": [...]}`
- **Scoped invalidation** — runtime supports `invalidate: [{context: "user", params: {user_id: 5}}]`
- **Param elevation** — shared params become required provider props, non-shared become optional
- **Schema export** — `x-mizan-functions` + `x-mizan-contexts` for codegen
- **`X-Mizan-Invalidate` header** — second invalidation transport for view-path responses (redirects, HTML)
- **Return-type branching** — data return → RPC path; `HttpResponse` return → view path
- **Scoped invalidation** — `affects_params` lambda; runtime supports `{context, params}` form
- **Auth guards** — `auth=True`, `auth='staff'`, `auth='superuser'`, `auth=callable`
- **JWT + session auth** — auto-detected, CSRF handled
- **MWT** — Mizan Web Token for Edge cache keying (separate secret from JWT/cache)
- **Shapes** — Pydantic + django-readers for typed query projections
- **WebSocket channels** — real-time bidirectional communication
- **Codegen** — generates typed React providers, hooks, mutations from schema
- **CDN-ready headers** — `Cache-Control`, deterministic JSON on context GETs, `no-store` on mutations
- **HMAC cache keying** — origin-side cache with cross-language HMAC conformance (Python + TypeScript pin)
- **Edge manifest** — `python manage.py export_edge_manifest`; both RPC and view-path functions
- **SSR bridge** — Django template backend → persistent Bun subprocess via JSON-RPC
- **`mizan-base` kernel** — framework-agnostic imperative client primitives (data/status/error owned by kernel)
- **Two-stage codegen** — Stage 1 emits framework-agnostic protocol layer; Stage 2 emits per-framework hooks (React, Vue, Svelte)
- **`mizan-ts`** — TypeScript backend adapter; proves the protocol is language-agnostic
### Next: X-Mizan-Invalidate Header
---
Second invalidation transport. For view responses (redirects, HTML), invalidation goes in an HTTP header instead of the JSON body. Both transports are first-class AFI spec.
### Next (in progress)
- Header format: `X-Mizan-Invalidate: user;user_id=5, notifications`
- Comma-separated contexts, semicolon-separated params per context
- Decorator auto-adds header to any HttpResponse with `affects=`
- Edge reads this header to purge cached pages
- Runtime also reads it on XHR/fetch responses (htmx path)
- **React adapter wrapper layer** — codegen emits `MizanContext` provider, `useMizan` hook, `DjangoError` class on top of the `mizan-base` kernel. Equivalent wrapper layers for Vue and Svelte adapters. The harness in `examples/django-react-site` is blocked on this.
- **Legacy `MizanProvider` removal (A1)** — `mizan-react/src/context.tsx` (~750 lines) replaced by codegen-emitted wrappers. Blocks v1 `mizan-react` publishing.
- **Forms migration to kernel (A3)** — `mizan-react/src/forms.ts` (~1163 lines) currently consumes legacy `MizanProvider`. Rewrite to use `mizanCall` from the kernel. Blocks A1.
- **Allauth extraction (A2)** — `legacy/allauth/` becomes `mizan-django-allauth` package consuming Mizan's public API.
- **Vue/Svelte e2e validation (A4)** — example apps exercising a live backend end-to-end, like `examples/django-react-site` does for React.
- **Test coverage gaps** — T1T12 in `ISSUES.md` (kernel state machine, view-path purge, SSR thread safety, retry logic, cross-language HMAC pin, etc.)
### Next: Return-Type Branching
---
`@client` serves both RPC developers (React/SPA) and view developers (htmx/templates). Return type determines behavior:
### Quality
- **Data return** (dict, Shape, BaseModel) → RPC path. Generates typed hooks. Invalidation in JSON body.
- **HttpResponse return** (render, redirect) → View path. No codegen. Invalidation in `X-Mizan-Invalidate` header.
Same decorator. Same `affects=`. Same invalidation graph. Two paths.
### Next: affects_params
Scoped invalidation with a lambda that extracts which params were affected:
```python
@client(affects='user', affects_params=lambda req: {'user_id': req.user.pk})
def update_name(request, name: str) -> dict:
...
```
Produces `invalidate: [{context: "user", params: {user_id: 5}}]` in JSON body or `X-Mizan-Invalidate: user;user_id=5` in header.
### Next: Edge Manifest
`mizan-generate --manifest` compiles the decorator registry + Django URL conf into static JSON for Edge:
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```
Edge reads the manifest at deploy time. When it receives `X-Mizan-Invalidate: user;user_id=5`, it resolves URL patterns with params and purges `/profile/5/` and `/api/mizan/ctx/user/?user_id=5`.
Generated alongside React code. Covers both RPC and view-path functions.
### Next: Codegen Rewrite
Generated code uses the runtime directly (`mizanFetch`, `mizanCall`, `registerContext`) instead of the legacy `MizanProvider` pattern. Mutations have zero invalidation knowledge — the runtime reads the server response.
### Next: SSR Bridge
Django renders React components server-side via a persistent Bun subprocess.
- Bun worker: stdin/stdout JSON-RPC, `renderToString`, component registry
- Django bridge: subprocess management, IPC, request synthesis
- Template tag: `{% mizan_render "ProfilePage" user_profile=profile %}`
- Hydration: `window.__MIZAN_SSR_DATA__` consumed by generated providers
- Generated contexts check SSR data before first fetch
- **H5** — Mutation hooks expose no loading/error state
- **H7** — Redis SCAN blocks request path at scale
- **H8** — Svelte codegen uses Svelte 4 stores; should use Svelte 5 runes
- **H9** — Svelte `destroy()` not auto-called (memory leak)
- **H12** — Forms `triggerValidation` captures stale data
- Medium issues (M1M18) per developer judgment
---
@@ -92,7 +54,7 @@ Django renders React components server-side via a persistent Bun subprocess.
Cloudflare Workers for automatic edge caching.
- Reads the Edge manifest to configure cache rules
- Reads the edge manifest to configure cache rules
- Context GETs cached at edge, keyed by context name + params
- Reads `X-Mizan-Invalidate` header from mutation responses to purge caches
- Reads JSON `invalidate` key from RPC responses for the same purpose
@@ -118,74 +80,13 @@ One-command deployment for Django + React apps.
---
## Protocol Spec (AFI)
## Reference
The protocol is the product. Two invalidation transports. Every endpoint CDN-ready.
Wire protocol shapes (context fetch, mutation call, invalidation transports) are documented in `CLAUDE.md`. Architectural details for specific subsystems live in `docs/`:
### Context fetch
```
GET /api/mizan/ctx/<name>/?param=value
200 OK
Cache-Control: public, max-age=0, s-maxage=31536000
{
"function_a": { ... },
"function_b": [ ... ]
}
```
### Mutation call (RPC path — JSON body transport)
```
POST /api/mizan/call/
Cache-Control: no-store
{
"result": { ... },
"invalidate": ["context_name"]
}
```
### Mutation call (View path — header transport)
```
POST /profile/update/
302 Found
Location: /profile/5/
Cache-Control: no-store
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Scoped invalidation (JSON)
```json
{
"result": { ... },
"invalidate": [
"notifications",
{ "context": "user", "params": { "user_id": 5 } }
]
}
```
### Scoped invalidation (Header)
```
X-Mizan-Invalidate: user;user_id=5, notifications
```
### Edge manifest
```json
{
"contexts": {
"user": {
"endpoints": ["/api/mizan/ctx/user/"],
"views": ["/profile/:user_id/"],
"params": ["user_id"]
}
}
}
```
- `docs/AFI_ARCHITECTURE.md` — package architecture, kernel model, adapter strategy
- `docs/CACHE_KEYING.md` — HMAC cache key derivation
- `docs/MWT_SPEC.md` — Mizan Web Token format
- `docs/SSR_ARCHITECTURE.md` — Django template backend, Bun bridge
- `docs/PSR_VS_EDGE.md` — protocol-level rendering vs. paid Edge layer
- `docs/PRODUCT_ARCHITECTURE.md` — product surface and pricing tiers

View File

@@ -0,0 +1,213 @@
# mizan-django
Django backend adapter for the Mizan protocol. One decorator on a server
function. Typed React client generated. Invalidation automatic.
## Install
```bash
uv add "mizan[channels]"
# or with allauth integration:
uv add "mizan[channels,allauth]"
```
## Setup
```python
# settings.py
INSTALLED_APPS = ["mizan", "myapp", ...]
MIZAN_CACHE_SECRET = "..." # 32-byte HMAC signing key
MIZAN_CACHE_REDIS_URL = "redis://localhost:6379/0"
MIZAN_MWT_SECRET = "..." # MWT signing key (separate from cache + JWT)
```
```python
# urls.py
from django.urls import include, path
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
]
```
```python
# asgi.py — for WebSocket / Channels support
from django.core.asgi import get_asgi_application
from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application())
```
## Define server functions
```python
# myapp/clients.py
from mizan.client import client
from mizan.setup import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
Auto-discover `clients.py` modules from each Django app:
```python
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self) -> None:
from mizan.setup import mizan_clients
mizan_clients("myapp") # imports myapp/clients.py — triggers @client side effects
```
## `@client` parameters
```python
@client # plain RPC function
@client(context="global") # singleton context — fetched once, SSR-hydrated
@client(context="user") # named context — fetched per provider mount
@client(affects="user") # mutation — invalidates the user context
@client(affects=user_profile) # mutation — invalidates a specific function
@client(websocket=True) # WebSocket transport (requires channels)
@client(auth=True) # requires authentication
@client(auth="staff") # requires is_staff
@client(auth="superuser") # requires is_superuser
@client(auth=lambda req: ...) # custom predicate
@client(route="/profile/<id>/") # view-path function (returns HttpResponse)
@client(rev=2) # cache revision (busts on bump)
```
## Forms
Django Forms become server functions + typed React hooks with Zod validation:
```python
from django import forms
from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(name="contact", title="Contact Us", submit_label="Send")
name = forms.CharField()
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
def on_submit_success(self, request):
send_email(self.cleaned_data)
return {"sent": True}
```
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
gets `useContactForm()`.
## Channels
WebSocket-native RPC via a flag flip:
```python
from pydantic import BaseModel
from mizan.channels import ReactChannel
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: str
class DjangoMessage(BaseModel):
text: str
user: str
def authorize(self, params):
return self.user.is_authenticated
def group(self, params):
return f"chat_{params.room}"
```
Frontend gets `useChatChannel({ room })`.
## Generate the frontend
The codegen is `mizan-generate` (in `protocol/mizan-generate/`). From your
frontend project, point a config at the Django backend and run the CLI:
```js
// frontend/django.config.mjs
import path from "path"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
export default {
source: {
django: {
managePath: path.join(root, "backend/manage.py"),
command: ["uv", "run", "python"],
env: {
PYTHONPATH: path.join(root, "backend"),
DJANGO_SETTINGS_MODULE: "myproject.settings",
},
},
},
output: "src/api",
}
```
```bash
npx mizan-generate --config django.config.mjs
```
The codegen drives Django's management command (`export_mizan_schema`) under
the hood, then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime
kernel) + Stage 2 (`<MizanContext>` provider, per-context providers,
`use{Hook}()` hooks) into `src/api/`.
```tsx
// app.tsx
import { MizanContext } from "./api"
export default function App({ children }) {
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
}
```
```tsx
// any component
import { useEcho, useCurrentUser } from "./api"
const echo = useEcho()
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
```
## Running tests
```bash
uv sync --extra dev --extra channels
uv run pytest
```
## Architecture
mizan-django is one of two reference backend adapters (the other is
`backends/mizan-fastapi`). Both implement the same Mizan protocol on top of
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
keys). See `docs/AFI_ARCHITECTURE.md`.

View File

@@ -5,6 +5,7 @@ description = "Django + React server functions framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"mizan-core",
"django>=5.0",
"django-ninja>=1.0",
"django-readers>=2.0",
@@ -12,6 +13,9 @@ dependencies = [
"PyJWT>=2.0",
]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
[project.optional-dependencies]
cache = [
"redis>=5.0",

View File

@@ -0,0 +1,142 @@
"""
mizan.cache — Origin-side cache implementing the Mizan cache protocol.
Simple key-value cache with HMAC-derived keys. No reverse indexes.
Scoped purge recomputes the key and deletes directly.
Broad purge uses key-prefix scan (rare operation).
Usage:
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from mizan_core.cache.backend import CacheBackend, MemoryCache, RedisCache
from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX
logger = logging.getLogger("mizan.cache")
_cache_instance: CacheBackend | None = None
_initialized = False
_init_lock = threading.Lock()
def get_cache() -> CacheBackend | None:
"""
Get the configured cache backend, or None if caching is disabled.
Thread-safe.
"""
global _cache_instance, _initialized
if _initialized:
return _cache_instance
with _init_lock:
if _initialized:
return _cache_instance
_initialized = True
try:
from mizan.setup.settings import get_settings
settings = get_settings()
if settings.cache_secret and settings.cache_redis_url:
_cache_instance = RedisCache(settings.cache_redis_url)
logger.info("Mizan cache enabled (Redis: %s)", settings.cache_redis_url)
elif settings.cache_secret and not settings.cache_redis_url:
logger.warning(
"MIZAN_CACHE_SECRET is set but MIZAN_CACHE_REDIS_URL is missing. "
"Cache is disabled."
)
elif settings.cache_redis_url and not settings.cache_secret:
logger.warning(
"MIZAN_CACHE_REDIS_URL is set but MIZAN_CACHE_SECRET is missing. "
"Cache is disabled."
)
except Exception:
logger.warning("Failed to initialize Mizan cache", exc_info=True)
_cache_instance = None
return _cache_instance
def set_cache(backend: CacheBackend | None) -> None:
"""Override the cache backend. For testing."""
global _cache_instance, _initialized
_cache_instance = backend
_initialized = True
def reset_cache() -> None:
"""Reset to uninitialized state. For testing teardown."""
global _cache_instance, _initialized
_cache_instance = None
_initialized = False
def cache_get(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
user_id: str | None = None,
rev: int = 0,
) -> bytes | None:
"""Look up a cached context response."""
key = derive_cache_key(secret, context, params, user_id, rev)
return backend.get(key)
def cache_put(
secret: str,
backend: CacheBackend,
context: str,
params: dict[str, Any],
value: bytes,
user_id: str | None = None,
rev: int = 0,
) -> None:
"""Store a context response in the cache."""
key = derive_cache_key(secret, context, params, user_id, rev)
backend.set(key, value)
def cache_purge(
backend: CacheBackend,
context: str,
params: dict[str, Any] | None = None,
secret: str | None = None,
user_id: str | None = None,
rev: int = 0,
) -> int:
"""
Purge cached entries for a context.
Scoped purge (params provided): recomputes the HMAC key and deletes
it directly. One DELETE, no index needed.
Broad purge (no params): scans by key prefix "ctx:{context}:*".
This is a rare operation (Tier 3 fallback in invalidation).
"""
if params is not None and len(params) > 0 and secret:
key = derive_cache_key(secret, context, params, user_id, rev)
return 1 if backend.delete(key) else 0
else:
prefix = f"{CONTEXT_KEY_PREFIX}{context}:"
return backend.delete_by_prefix(prefix)
__all__ = [
"CacheBackend",
"MemoryCache",
"RedisCache",
"get_cache",
"set_cache",
"reset_cache",
"cache_get",
"cache_put",
"cache_purge",
]

View File

@@ -523,6 +523,47 @@ def __getattr__(name):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# =============================================================================
# Core Registry Extension
# =============================================================================
class _ChannelsExtension:
"""
Plugs the channel registry into mizan_core.registry as the 'channels'
extension. Schema output goes under schema['channels'] in the unified
registry export consumed by codegen.
"""
def all(self) -> dict:
return dict(_registry)
def schema(self) -> dict:
out: dict[str, Any] = {}
for name, channel_class in _registry.items():
channel_schema: dict[str, Any] = {
"name": name,
"type": "channel",
"bidirectional": False,
}
if getattr(channel_class, "Params", None):
channel_schema["params"] = channel_class.Params.model_json_schema()
if getattr(channel_class, "ReactMessage", None):
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
channel_schema["bidirectional"] = True
if getattr(channel_class, "DjangoMessage", None):
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
out[name] = channel_schema
return out
def clear(self) -> None:
_registry.clear()
from mizan_core.registry import register_extension as _register_extension
_register_extension("channels", _ChannelsExtension())
# =============================================================================
# Exports
# =============================================================================

View File

@@ -400,7 +400,7 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
- User context from WebSocket session is passed to function
"""
from mizan.client.executor import execute_function, FunctionError
from mizan.setup.registry import get_function
from mizan_core.registry import get_function
request_id = content.get("id")
fn_name = content.get("fn")

View File

@@ -2,16 +2,24 @@
mizan.client - Server function implementation.
This subpackage contains everything needed to make server functions work:
- The @client decorator
- ServerFunction base class
- Function execution logic
- JWT authentication (integral to server functions)
- The @client decorator (lives in mizan_core.client.function)
- ServerFunction base class (mizan_core.client.function)
- Function execution logic (.executor Django-specific dispatch)
- JWT authentication (.jwt Django-specific session integration)
Usage:
from mizan.client import client, ServerFunction, compose
"""
from .function import (
# Register the Django framework response base so view-path detection works
# in mizan_core.client.function. Has to happen before any @client-decorated
# code is evaluated.
from django.http import HttpResponseBase as _HttpResponseBase
from mizan_core.client.function import set_framework_response_base as _set_response_base
_set_response_base(_HttpResponseBase)
from mizan_core.client.function import (
# Decorator
client,
# Context markers

View File

@@ -23,12 +23,12 @@ from enum import Enum
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http import HttpRequest, HttpResponse, HttpResponseBase, JsonResponse
from django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan.setup.registry import get_function, get_context_groups
from mizan_core.registry import get_function, get_context_groups
from mizan.setup.settings import get_settings
if TYPE_CHECKING:
@@ -161,6 +161,42 @@ def _check_auth_requirement(
return None
_cache_log = logging.getLogger("mizan.cache")
def _purge_cache_for_invalidation(
invalidate: list,
request: HttpRequest | None = None,
) -> None:
"""Purge origin-side cache for invalidation targets. Includes user_id if available."""
cache = get_cache()
if cache is None:
return
settings = get_settings()
if not settings.cache_secret:
return
user_id = None
if request and hasattr(request, 'user') and hasattr(request.user, 'pk'):
uid = getattr(request.user, 'pk', None)
if uid is not None:
user_id = str(uid)
try:
for entry in invalidate:
if isinstance(entry, str):
cache_purge(cache, entry)
elif isinstance(entry, dict):
cache_purge(
cache, entry["context"], entry.get("params"),
secret=settings.cache_secret,
user_id=user_id,
)
except Exception:
_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.
@@ -310,7 +346,7 @@ def execute_function(
request: HttpRequest,
fn_name: str,
input_data: dict[str, Any] | None = None,
) -> FunctionResult | FunctionError:
) -> "FunctionResult | FunctionError | HttpResponseBase":
"""
Execute a registered server function.
@@ -444,10 +480,11 @@ def execute_function(
from django.http import HttpResponseBase
if isinstance(output, HttpResponseBase):
# View path — add invalidation header, pass through the response
# View path — add invalidation header + purge origin cache
invalidate = _resolve_invalidation(view_class, input_data)
if invalidate:
output["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate)
_purge_cache_for_invalidation(invalidate, request)
output["Cache-Control"] = "no-store"
return output
@@ -457,6 +494,46 @@ def execute_function(
return FunctionResult(data=output.model_dump())
def _try_mwt_auth(request: HttpRequest) -> bool:
"""
Attempt to authenticate the request using MWT (Mizan Web Token).
Checks the X-Mizan-Token header. If present and valid, sets request.user
to an MWTUser. Returns True on success, False if no MWT header or invalid.
"""
token = request.META.get("HTTP_X_MIZAN_TOKEN", "")
if not token:
return False
try:
settings = get_settings()
if not settings.mwt_secret:
logging.getLogger("mizan.mwt").warning(
"X-Mizan-Token header present but MIZAN_MWT_SECRET is not configured"
)
return False
from mizan_core.mwt import decode_mwt, MWTUser
payload = decode_mwt(token, settings.mwt_secret)
if payload is None:
return False
request.user = MWTUser(payload)
request._mizan_mwt_authenticated = True
return True
except Exception:
logging.getLogger("mizan.mwt").warning(
"MWT authentication failed unexpectedly", exc_info=True
)
return False
def _has_mwt_header(request: HttpRequest) -> bool:
"""Check if request has an X-Mizan-Token header."""
return bool(request.META.get("HTTP_X_MIZAN_TOKEN", ""))
def _try_jwt_auth(request: HttpRequest) -> bool:
"""
Attempt to authenticate the request using JWT.
@@ -502,43 +579,45 @@ def _has_jwt_header(request: HttpRequest) -> bool:
return auth_header.startswith("Bearer ")
def _csrf_protect_unless_jwt(view_func):
def _csrf_protect_unless_token(view_func):
"""
Decorator that applies CSRF protection unless JWT auth is used.
Decorator that applies CSRF protection unless token auth is used.
JWT tokens are self-authenticating (the token itself proves the request
is legitimate), so CSRF protection is not needed.
MWT (X-Mizan-Token) is checked first, then legacy JWT (Authorization: Bearer).
Both are self-authenticating, so CSRF protection is not needed.
Security: If JWT is provided but invalid, reject the request - do NOT
fall back to session auth. This prevents attacks where an invalid token
is sent alongside a valid session cookie.
Security: If a token is provided but invalid, reject the request - do NOT
fall back to session auth.
"""
csrf_protected_view = csrf_protect(view_func)
@wraps(view_func)
def wrapper(request: HttpRequest, *args, **kwargs):
# Check if JWT header is present
has_jwt = _has_jwt_header(request)
if has_jwt:
# JWT header present - try to authenticate
if _try_jwt_auth(request):
# JWT valid - skip CSRF, proceed
# MWT takes priority
if _has_mwt_header(request):
if _try_mwt_auth(request):
return view_func(request, *args, **kwargs)
else:
# JWT invalid - reject (do NOT fall back to session)
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired JWT token",
).to_response(status=401)
else:
# No JWT - use session auth with CSRF
return csrf_protected_view(request, *args, **kwargs)
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired MWT",
).to_response(status=401)
# Legacy JWT fallback
if _has_jwt_header(request):
if _try_jwt_auth(request):
return view_func(request, *args, **kwargs)
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired JWT token",
).to_response(status=401)
# No token — session auth with CSRF
return csrf_protected_view(request, *args, **kwargs)
return wrapper
@_csrf_protect_unless_jwt
@_csrf_protect_unless_token
def function_call_view(request: HttpRequest) -> JsonResponse:
"""
Django view for handling function calls (HTTP fallback for WebSocket RPC).
@@ -659,22 +738,9 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
response = JsonResponse(response_data)
response["Cache-Control"] = "no-store"
# Always set the header transport too (Edge reads this)
if invalidate_contexts:
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
# Purge origin-side cache for invalidated contexts
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
if cache is not None:
try:
for entry in invalidate_contexts:
if isinstance(entry, str):
cache_purge(cache, entry)
elif isinstance(entry, dict):
cache_purge(cache, entry["context"], entry.get("params"))
except Exception:
_cache_log.warning("Cache purge failed", exc_info=True)
_purge_cache_for_invalidation(invalidate_contexts, request)
return response
@@ -732,20 +798,30 @@ def execute_context(
def _jwt_auth_only(view_func):
"""
Decorator that handles JWT auth for GET endpoints (no CSRF needed for GET).
Decorator that handles token auth for GET endpoints (no CSRF needed for GET).
Checks MWT first, then legacy JWT.
"""
@wraps(view_func)
def wrapper(request: HttpRequest, *args, **kwargs):
has_jwt = _has_jwt_header(request)
if has_jwt:
# MWT takes priority
if _has_mwt_header(request):
if _try_mwt_auth(request):
return view_func(request, *args, **kwargs)
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired MWT",
).to_response(status=401)
# Legacy JWT fallback
if _has_jwt_header(request):
if _try_jwt_auth(request):
return view_func(request, *args, **kwargs)
else:
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired JWT token",
).to_response(status=401)
# No JWT — session auth (no CSRF needed for GET)
return FunctionError(
code=ErrorCode.UNAUTHORIZED,
message="Invalid or expired JWT token",
).to_response(status=401)
# No token — session auth (no CSRF needed for GET)
return view_func(request, *args, **kwargs)
return wrapper
@@ -775,22 +851,48 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
params = request.GET.dict()
# Origin-side cache lookup
# Resolve effective rev and cache policy across all functions in this context
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
groups = get_context_groups()
fn_names = groups.get(context_name, [])
effective_rev = 0
effective_cache: 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:
meta = getattr(fn_cls, "_meta", {})
fn_rev = meta.get("rev", 0)
effective_rev = max(effective_rev, fn_rev)
fn_cache = meta.get("cache", True)
if fn_cache is False:
effective_cache = False
break
elif isinstance(fn_cache, int):
if effective_cache is True:
effective_cache = fn_cache
else:
effective_cache = min(effective_cache, fn_cache)
# Origin-side cache lookup (skip if cache=False)
cache_backend = get_cache()
cache_settings = get_settings()
user_id = None
if hasattr(request, "user") and hasattr(request.user, "pk") and request.user.pk:
user_id = str(request.user.pk)
if cache is not None and cache_settings.cache_secret:
use_cache = (
cache_backend is not None
and cache_settings.cache_secret
and effective_cache is not False
)
if use_cache:
try:
cached = cache_get(
cache_settings.cache_secret, cache, context_name, params,
user_id=user_id,
cache_settings.cache_secret, cache_backend, context_name, params,
user_id=user_id, rev=effective_rev,
)
if cached is not None:
response = HttpResponse(cached, content_type="application/json")
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
response["Cache-Control"] = "no-store"
response["X-Mizan-Cache"] = "HIT"
return response
except Exception:
@@ -815,19 +917,16 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
# Deterministic JSON (sorted keys) for consistent cache keys
response = JsonResponse(result.data, json_dumps_params={"sort_keys": True})
# CDN-ready headers
# max-age=0: browser always revalidates (gets 304 from CDN if unchanged)
# s-maxage=31536000: CDN caches forever; purge is the freshness mechanism
# No Vary header — Cloudflare ignores Vary for personalized content.
# User-scoped cache keying will use HMAC-based keys instead.
response["Cache-Control"] = "public, max-age=0, s-maxage=31536000"
# Mizan's protocol layers handle caching (origin Redis, Edge Worker).
# The browser and non-Mizan intermediaries must not cache.
response["Cache-Control"] = "no-store"
# Store in origin-side cache
if cache is not None and cache_settings.cache_secret:
# Store in origin-side cache (skip if cache=False)
if use_cache:
try:
cache_put(
cache_settings.cache_secret, cache, context_name, params,
response.content, user_id=user_id,
cache_settings.cache_secret, cache_backend, context_name, params,
response.content, user_id=user_id, rev=effective_rev,
)
response["X-Mizan-Cache"] = "MISS"
except Exception:

View File

@@ -26,7 +26,7 @@ if TYPE_CHECKING:
from django import forms
from ninja import NinjaAPI
from mizan.setup.registry import get_registry, get_schema, get_context_groups, get_function
from mizan_core.registry import get_registry, get_schema, get_context_groups, get_function
__all__ = [
@@ -428,6 +428,12 @@ def generate_edge_manifest(
fn_entry["methods"] = meta.get("methods", ["GET"])
page_routes.append(fn_route)
# Cache protocol metadata
if "rev" in meta:
fn_entry["rev"] = meta["rev"]
if "cache" in meta:
fn_entry["cache"] = meta["cache"]
functions_meta.append(fn_entry)
sorted_params = sorted(param_names)

View File

@@ -292,8 +292,8 @@ def _register_form_as_server_functions(form_class: type) -> None:
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
from .schema_utils import build_form_schema
from .validation_utils import validate_form_instance
from mizan.setup.registry import register
from mizan.client.function import ServerFunction
from mizan_core.registry import register
from mizan_core.client.function import ServerFunction
config: mizanFormMeta = form_class.mizan
form_name = config.name
@@ -484,8 +484,8 @@ def _register_formset_functions(
from .schema_utils import build_form_schema
from .validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data
from mizan.setup.registry import register
from mizan.client.function import ServerFunction
from mizan_core.registry import register
from mizan_core.client.function import ServerFunction
formset_class = formset_factory(form_class)
@@ -630,3 +630,48 @@ def _register_formset_functions(
FormsetSubmitFunction.__name__ = f"{form_name}_formset_submit"
FormsetSubmitFunction.Output = FormsetSubmitPass
register(FormsetSubmitFunction, f"{form_name}.formset.submit")
# ─── Public helpers ─────────────────────────────────────────────────────────
def register_form(
form_class: type,
name: str,
submit_handler: Any = None,
) -> None:
"""
Register a Django Form class as Mizan server functions.
Creates and registers `{name}.schema`, `{name}.validate`, and
`{name}.submit` (if a submit_handler is provided).
"""
from mizan_core.client.function import create_form_functions
from mizan_core.registry import register
schema_fn, validate_fn, submit_fn = create_form_functions(
form_class, name, submit_handler
)
register(schema_fn, f"{name}.schema")
register(validate_fn, f"{name}.validate")
if submit_fn:
register(submit_fn, f"{name}.submit")
def get_forms() -> dict[str, list]:
"""
Group registered form-related functions by their form name.
Returns a mapping like:
{"contact": [ContactSchema, ContactValidate, ContactSubmit], ...}
"""
from mizan_core.registry import get_all_functions
forms: dict[str, list] = {}
for name, cls in get_all_functions().items():
meta = getattr(cls, "_meta", {})
if not meta.get("form"):
continue
form_name = meta.get("form_name")
forms.setdefault(form_name, []).append(cls)
return forms

View File

@@ -1,7 +1,7 @@
"""
JWT Server Functions
JWT & MWT Server Functions
JWT token operations exposed as mizan server functions.
Token operations exposed as mizan server functions.
Works over WebSocket RPC (primary) or HTTP fallback.
"""
@@ -10,6 +10,7 @@ from pydantic import BaseModel
from mizan.client import client
from mizan.jwt.tokens import create_token_pair, refresh_tokens
from mizan_core.mwt import create_mwt
class TokenPairOutput(BaseModel):
@@ -99,3 +100,42 @@ def jwt_refresh(request: HttpRequest, refresh_token: str) -> TokenPairOutput:
refresh_token=tokens.refresh_token,
expires_in=tokens.expires_in,
)
# ── MWT (Mizan Web Token) ──────────────────────────────────────────────
class MWTOutput(BaseModel):
"""MWT token response."""
token: str
expires_in: int
@client
def mwt_obtain(request: HttpRequest) -> MWTOutput:
"""
Obtain a Mizan Web Token from an authenticated session.
Requires session authentication (cookie-based login).
Returns an MWT for the X-Mizan-Token header stateless,
cache-aware authentication with permission staleness detection.
Usage (from frontend):
const { token, expires_in } = await call('mwt_obtain')
// Use token in X-Mizan-Token header
"""
user = request.user
if not user.is_authenticated:
raise PermissionError("Authentication required")
from mizan.setup.settings import get_settings
settings = get_settings()
if not settings.mwt_secret:
raise ValueError(
"MIZAN_MWT_SECRET is not configured. MWT requires a signing secret."
)
token = create_mwt(user, settings.mwt_secret, ttl=settings.mwt_ttl)
return MWTOutput(token=token, expires_in=settings.mwt_ttl)

View File

@@ -1,36 +1,40 @@
"""
mizan.setup - Integration and registration utilities.
mizan.setup - Django integration helpers.
This subpackage contains everything developers need to integrate mizan:
- Registry for server functions and channels
- Auto-discovery for apps
- Configuration settings
Usage:
from mizan.setup import mizan_clients, register, get_function
The function/composition registry now lives in `mizan_core.registry`.
Channels register themselves through the channel-specific registry in
`mizan.channels`. Forms register through `mizan.forms`. This module
re-exports the helpers that Django mizan users typically reach for, so
`from mizan.setup import register, get_function, mizan_clients, ` keeps
working as a single curated surface.
"""
from .registry import (
from mizan_core.registry import (
register,
register_as,
register_form,
register_compose,
get_function,
get_channel,
get_compose,
get_view,
get_all_functions,
get_all_channels,
get_all_compositions,
get_registry,
get_schema,
get_contexts,
get_context_groups,
get_forms,
validate_registry,
clear_registry,
)
from mizan.channels import (
get_channel,
get_registered_channels as get_all_channels,
)
from mizan.forms import (
register_form,
get_forms,
)
from .discovery import (
mizan_clients,
mizan_module,
@@ -52,7 +56,6 @@ __all__ = [
"get_function",
"get_channel",
"get_compose",
"get_view",
"get_all_functions",
"get_all_channels",
"get_all_compositions",

View File

@@ -18,8 +18,8 @@ from typing import Any
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
from .registry import register, get_function
from mizan.client.function import ServerFunction
from mizan_core.registry import register, get_function
from mizan_core.client.function import ServerFunction
class _RegisterServerFunctions:

View File

@@ -17,12 +17,18 @@ class mizanSettings:
# Whether to expose function names in DEBUG mode errors
debug_expose_names: bool
# Cache signing secret (required when cache is enabled)
# Cache HMAC signing secret (required when cache is enabled)
cache_secret: str | None
# Redis URL for cache backend (None = cache disabled)
cache_redis_url: str | None
# MWT signing secret (separate from cache secret for blast radius containment)
mwt_secret: str | None
# MWT token lifetime in seconds (default: 300 = 5 minutes)
mwt_ttl: int
@lru_cache
def get_settings() -> mizanSettings:
@@ -38,6 +44,8 @@ def get_settings() -> mizanSettings:
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
cache_secret=getattr(django_settings, "MIZAN_CACHE_SECRET", None),
cache_redis_url=getattr(django_settings, "MIZAN_CACHE_REDIS_URL", None),
mwt_secret=getattr(django_settings, "MIZAN_MWT_SECRET", None),
mwt_ttl=getattr(django_settings, "MIZAN_MWT_TTL", 300),
)

View File

@@ -0,0 +1,25 @@
"""
mizan.ssr — Server-side rendering via Bun subprocess.
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
Then use Django's standard render():
return render(request, 'ProfilePage', {'user_id': 5})
The component name is the template name. The context dict becomes props.
"""
from .backend import MizanTemplates
__all__ = ["MizanTemplates"]

View File

@@ -0,0 +1,100 @@
"""
Mizan SSR Template Backend — Django template engine that renders React via Bun.
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'DIRS': [BASE_DIR / 'frontend'],
'OPTIONS': {
'worker': 'path/to/mizan-ssr/src/worker.tsx',
},
},
]
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
"""
from __future__ import annotations
import os
from typing import Any
from django.template import TemplateDoesNotExist
from django.template.backends.base import BaseEngine
from django.utils.safestring import mark_safe
from .bridge import SSRBridge
class MizanTemplate:
"""Renders a .tsx/.jsx file via the SSR bridge."""
def __init__(self, file_path: str, bridge: SSRBridge) -> None:
self.file_path = file_path
self.origin = None
self._bridge = bridge
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
import json as _json
props = dict(context) if context else {}
props.pop("request", None)
props.pop("csrf_token", None)
result = self._bridge.render(self.file_path, props)
# Serialize props as hydration data for client-side React
hydration_json = _json.dumps(props, sort_keys=True, default=str)
return mark_safe(
f'<div id="mizan-root">{result.html}</div>'
f'<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>'
)
class MizanTemplates(BaseEngine):
"""
Django template backend that renders React components via Bun.
Template names are file paths resolved against DIRS.
Same model as Django's built-in template engines.
"""
def __init__(self, params: dict[str, Any]) -> None:
options = params.pop("OPTIONS", {})
params.setdefault("NAME", "mizan")
params.setdefault("APP_DIRS", False)
super().__init__(params)
self._worker = options.get("worker")
self._timeout = options.get("timeout", 5)
self._bridge: SSRBridge | None = None
if not self._worker:
raise ValueError(
"MizanTemplates requires OPTIONS['worker'] — "
"the path to mizan-ssr's worker.tsx"
)
def get_bridge(self) -> SSRBridge:
if self._bridge is None:
self._bridge = SSRBridge(
worker_path=self._worker,
timeout=self._timeout,
)
return self._bridge
def get_template(self, template_name: str) -> MizanTemplate:
for dir_path in self.dirs:
file_path = os.path.join(dir_path, template_name)
if os.path.isfile(file_path):
return MizanTemplate(
os.path.abspath(file_path),
self.get_bridge(),
)
raise TemplateDoesNotExist(template_name)
def from_string(self, template_code: str) -> MizanTemplate:
raise TemplateDoesNotExist(
"MizanTemplates renders .tsx files, not template strings."
)

View File

@@ -0,0 +1,181 @@
"""
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
Response: {"id": 1, "html": "<div>...</div>"}
The subprocess stays alive across requests. It is started on first use
and restarted automatically if it crashes.
"""
from __future__ import annotations
import atexit
import json
import logging
import subprocess
import threading
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("mizan.ssr")
@dataclass
class RenderResult:
"""Result of an SSR render call."""
html: str
class SSRBridge:
"""
Manages a persistent Bun subprocess for server-side rendering.
Thread-safe. Multiple Django workers can call render() concurrently.
Request-response matching via message IDs.
"""
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
self._worker_path = worker_path
self._timeout = timeout
self._proc: subprocess.Popen | None = None
self._lock = threading.Lock()
self._write_lock = threading.Lock() # Serializes stdin writes
self._counter = 0
self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None
self._ready = threading.Event()
# Ensure cleanup on process exit
atexit.register(self.shutdown)
def _ensure_running(self) -> None:
"""Start the Bun subprocess if it's not running."""
if self._proc is not None and self._proc.poll() is None:
return
if self._proc is not None:
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
self._ready.clear()
self._proc = subprocess.Popen(
["bun", "run", self._worker_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._reader_thread = threading.Thread(
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
)
self._reader_thread.start()
# Wait for the "ready" signal from the worker
if not self._ready.wait(timeout=self._timeout):
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
self.shutdown()
raise TimeoutError("SSR worker failed to start")
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
def _read_responses(self) -> None:
"""Background thread that reads JSON responses from stdout."""
try:
for line in self._proc.stdout:
if isinstance(line, bytes):
line = line.decode("utf-8")
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
continue
msg_id = msg.get("id")
# Ready signal (id=0)
if msg_id == 0 and msg.get("ready"):
self._ready.set()
continue
if msg_id is not None and msg_id in self._pending:
self._results[msg_id] = msg
self._pending[msg_id].set()
except Exception:
logger.warning("SSR reader thread exited", exc_info=True)
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
"""
Render a React component to HTML.
Args:
file: Absolute path to the .tsx/.jsx file to render.
props: Props to pass to the component.
Returns:
RenderResult with the HTML string.
Raises:
TimeoutError: If the render takes longer than the configured timeout.
RuntimeError: If the render fails.
"""
with self._lock:
self._ensure_running()
self._counter += 1
msg_id = self._counter
event = threading.Event()
self._pending[msg_id] = event
request = json.dumps({
"id": msg_id,
"method": "render",
"params": {"file": file, "props": props or {}},
}) + "\n"
# Serialize stdin writes to prevent interleaving from concurrent threads
with self._write_lock:
try:
self._proc.stdin.write(request.encode("utf-8"))
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as e:
self._pending.pop(msg_id, None)
raise RuntimeError(f"SSR worker pipe broken: {e}")
if not event.wait(self._timeout):
self._pending.pop(msg_id, None)
raise TimeoutError(
f"SSR render of '{file}' timed out after {self._timeout}s"
)
self._pending.pop(msg_id, None)
result = self._results.pop(msg_id)
if "error" in result:
raise RuntimeError(f"SSR render failed: {result['error']}")
return RenderResult(html=result["html"])
def shutdown(self) -> None:
"""Stop the Bun subprocess."""
if self._proc is not None:
try:
self._proc.stdin.close()
except Exception:
pass
try:
self._proc.terminate()
self._proc.wait(timeout=3)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
logger.info("Bun SSR worker stopped")

View File

@@ -32,7 +32,7 @@ from mizan.client.executor import (
ErrorCode,
)
from mizan.client import client
from mizan.setup.registry import clear_registry, register
from mizan_core.registry import clear_registry, register
from pydantic import BaseModel

View File

@@ -27,7 +27,7 @@ from django.test import RequestFactory, TestCase, TransactionTestCase, override_
from pydantic import BaseModel
from mizan.client.executor import FunctionResult, execute_function, function_call_view
from mizan.setup.registry import clear_registry
from mizan_core.registry import clear_registry
from mizan.client import client
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
def setup_benchmark_functions():
"""Register benchmark server functions."""
from mizan.setup.registry import register
from mizan_core.registry import register
clear_registry()

View File

@@ -928,13 +928,13 @@ class WebSocketRPCTests(TestCase):
def setUp(self):
# Clear mizan registry
from mizan.setup.registry import clear_registry
from mizan_core.registry import clear_registry
clear_registry()
# Register test functions
from mizan.client import client
from mizan.setup.registry import register
from mizan_core.registry import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
@@ -964,7 +964,7 @@ class WebSocketRPCTests(TestCase):
register(rpc_auth_required, "rpc_auth_required")
def tearDown(self):
from mizan.setup.registry import clear_registry
from mizan_core.registry import clear_registry
clear_registry()

View File

@@ -17,15 +17,15 @@ from mizan.client.executor import (
execute_function,
execute_context,
)
from mizan.setup.registry import (
from mizan_core.registry import (
clear_registry,
register,
register_as,
register_form,
get_schema,
get_contexts,
get_function,
)
from mizan.forms import register_form
from mizan.client import ServerFunction, client, ReactContext, GlobalContext
from mizan.channels import ReactChannel
@@ -597,7 +597,7 @@ class ContextTests(TestCase):
def test_context_groups(self):
"""Test get_context_groups() groups functions by context name."""
from mizan.setup.registry import get_context_groups
from mizan_core.registry import get_context_groups
UserCtx = ReactContext("user")
@@ -1019,9 +1019,8 @@ class ServerDrivenInvalidationTests(TestCase):
self.assertIn("team_info", data)
self.assertEqual(data["team_info"]["name"], "team_3")
# CDN-ready headers
self.assertIn("public", response["Cache-Control"])
self.assertIn("s-maxage", response["Cache-Control"])
# Mizan handles caching via its protocol; origin emits no-store
self.assertEqual(response["Cache-Control"], "no-store")
def test_context_error_not_cached(self):
"""Context fetch errors must not be cached."""
@@ -1148,8 +1147,8 @@ class ChannelTests(TestCase):
def test_register_channel(self):
"""Test channel registration."""
from mizan.channels import register as register_channel, get_channel
@register_as("test-channel")
class TestChannel(ReactChannel):
class DjangoMessage(BaseModel):
text: str
@@ -1157,15 +1156,13 @@ class ChannelTests(TestCase):
def authorize(self, params=None):
return True
from mizan.setup.registry import get_channel
channel = get_channel("test-channel")
self.assertEqual(channel, TestChannel)
register_channel(TestChannel, "test-channel")
self.assertEqual(get_channel("test-channel"), TestChannel)
def test_channel_schema_export(self):
"""Test channel schema export."""
from mizan.channels import register as register_channel
@register_as("chat")
class ChatChannel(ReactChannel):
class Params(BaseModel):
room: int
@@ -1180,6 +1177,8 @@ class ChannelTests(TestCase):
def authorize(self, params):
return True
register_channel(ChatChannel, "chat")
schema = get_schema()
self.assertIn("channels", schema)
@@ -1194,8 +1193,8 @@ class ChannelTests(TestCase):
def test_server_push_only_channel(self):
"""Test channel without ReactMessage (server-push only)."""
from mizan.channels import register as register_channel
@register_as("notifications")
class NotificationsChannel(ReactChannel):
class DjangoMessage(BaseModel):
title: str
@@ -1203,6 +1202,7 @@ class ChannelTests(TestCase):
def authorize(self, params=None):
return True
register_channel(NotificationsChannel, "notifications")
schema = get_schema()
notif_schema = schema["channels"]["notifications"]
@@ -1785,9 +1785,8 @@ class HTTPIntegrationTests(TestCase):
self.assertEqual(data["user_profile"]["name"], "user_5")
self.assertEqual(data["user_orders"]["count"], 50)
# CDN headers
self.assertIn("public", response["Cache-Control"])
self.assertIn("s-maxage", response["Cache-Control"])
# Mizan handles caching; origin emits no-store
self.assertEqual(response["Cache-Control"], "no-store")
def test_context_fetch_string_to_int_coercion(self):
"""Query params arrive as strings. Pydantic must coerce to int."""
@@ -2061,7 +2060,7 @@ class ReturnTypeBranchingTests(TestCase):
register(profile_page, "profile_page")
# It's in the context groups (for invalidation graph)
from mizan.setup.registry import get_context_groups
from mizan_core.registry import get_context_groups
groups = get_context_groups()
self.assertIn("user", groups)
self.assertIn("profile_page", groups["user"])
@@ -2142,16 +2141,10 @@ class EdgeCompatibilityTests(TestCase):
# ── Cache-Control correctness ───────────────────────────────────────────
def test_context_get_is_cacheable(self):
"""Context GET has Cache-Control that allows CDN caching."""
def test_context_get_no_store(self):
"""Context GET emits no-store. Mizan's protocol layers handle caching."""
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
cc = response["Cache-Control"]
self.assertIn("public", cc)
self.assertIn("s-maxage", cc)
# Must NOT have no-store or private
self.assertNotIn("no-store", cc)
self.assertNotIn("private", cc)
self.assertEqual(response["Cache-Control"], "no-store")
def test_mutation_post_not_cacheable(self):
"""Mutation POST has no-store. CDN must never cache mutations."""
@@ -2786,100 +2779,44 @@ class PrivateAndRouteTests(TestCase):
# ── Cache conformance tests ────────────────────────────────────────────────
class CacheKeyDerivationTests(TestCase):
"""Tests that HMAC cache key derivation is deterministic and correct."""
SECRET = "test-cache-secret"
def test_deterministic_output(self):
"""Same inputs always produce the same key."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
self.assertEqual(key1, key2)
self.assertEqual(len(key1), 64) # SHA-256 hex digest
def test_param_order_irrelevant(self):
"""Parameter ordering does not affect the key."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
self.assertEqual(key1, key2)
def test_different_user_ids_different_keys(self):
"""Different user_ids produce different cache keys."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
self.assertNotEqual(key1, key2)
def test_rev_changes_key(self):
"""Different rev values produce different cache keys."""
from mizan.cache.keys import derive_cache_key
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
self.assertNotEqual(key1, key2)
def test_no_delimiter_collision(self):
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
from mizan.cache.keys import derive_cache_key
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
self.assertNotEqual(key1, key2)
def test_public_vs_user_scoped(self):
"""Public (no user_id) and user-scoped produce different keys."""
from mizan.cache.keys import derive_cache_key
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
self.assertNotEqual(public, scoped)
class CacheBackendTests(TestCase):
"""Tests for MemoryCache backend operations."""
def setUp(self):
from mizan.cache.backend import MemoryCache
from mizan_core.cache.backend import MemoryCache
self.cache = MemoryCache()
def test_get_miss(self):
"""Empty cache returns None."""
self.assertIsNone(self.cache.get("nonexistent"))
def test_put_then_get(self):
def test_set_then_get(self):
"""Store and retrieve a value."""
self.cache.put("key1", b'{"data": true}', ["mizan:idx:ctx"])
self.cache.set("key1", b'{"data": true}')
result = self.cache.get("key1")
self.assertEqual(result, b'{"data": true}')
def test_index_tracking(self):
"""Put adds the key to specified indexes."""
self.cache.put("key1", b"v1", ["mizan:idx:user", "mizan:idx:user:user_id=5"])
self.assertIn("key1", self.cache.get_index("mizan:idx:user"))
self.assertIn("key1", self.cache.get_index("mizan:idx:user:user_id=5"))
def test_delete_many(self):
"""Delete multiple keys at once."""
self.cache.put("k1", b"v1", [])
self.cache.put("k2", b"v2", [])
count = self.cache.delete_many(["k1", "k2"])
self.assertEqual(count, 2)
def test_delete(self):
"""Delete a key."""
self.cache.set("k1", b"v1")
self.assertTrue(self.cache.delete("k1"))
self.assertIsNone(self.cache.get("k1"))
self.assertIsNone(self.cache.get("k2"))
def test_delete_by_prefix(self):
"""Delete by prefix removes matching keys only."""
self.cache.set("ctx:user:abc", b"v1")
self.cache.set("ctx:user:def", b"v2")
self.cache.set("ctx:products:ghi", b"v3")
count = self.cache.delete_by_prefix("ctx:user:")
self.assertEqual(count, 2)
self.assertIsNone(self.cache.get("ctx:user:abc"))
self.assertIsNotNone(self.cache.get("ctx:products:ghi"))
def test_clear(self):
"""Clear removes everything."""
self.cache.put("k1", b"v1", ["idx1"])
self.cache.set("k1", b"v1")
self.cache.clear()
self.assertIsNone(self.cache.get("k1"))
self.assertEqual(self.cache.get_index("idx1"), set())
class CachePurgeTests(TestCase):
@@ -2889,7 +2826,7 @@ class CachePurgeTests(TestCase):
def setUp(self):
from mizan.cache import cache_put, set_cache
from mizan.cache.backend import MemoryCache
from mizan_core.cache.backend import MemoryCache
self.cache = MemoryCache()
set_cache(self.cache)
@@ -2903,10 +2840,10 @@ class CachePurgeTests(TestCase):
reset_cache()
def test_scoped_purge(self):
"""Purging user;user_id=5 removes only that entry."""
"""Purging user;user_id=5 recomputes key and deletes directly."""
from mizan.cache import cache_purge, cache_get
count = cache_purge(self.cache, "user", {"user_id": "5"})
count = cache_purge(self.cache, "user", {"user_id": "5"}, secret=self.SECRET)
self.assertEqual(count, 1)
# user_id=5 is gone
@@ -2933,7 +2870,7 @@ class CacheIntegrationTests(TestCase):
self.factory = RequestFactory()
from mizan.cache import set_cache
from mizan.cache.backend import MemoryCache
from mizan_core.cache.backend import MemoryCache
from mizan.setup.settings import clear_settings_cache
self.cache = MemoryCache()
@@ -3028,3 +2965,440 @@ class CacheIntegrationTests(TestCase):
# User 6 should still be cached
r6 = self._fetch_context("user_id=6")
self.assertEqual(r6.get("X-Mizan-Cache"), "HIT")
# ── Rev and cache parameter tests ──────────────────────────────────────────
class RevParameterTests(TestCase):
"""Tests for the @client(rev=N) parameter."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_rev_stored_in_meta(self):
"""@client(rev=2) stores rev in function metadata."""
Ctx = ReactContext("data")
@client(context=Ctx, rev=2)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertEqual(meta["rev"], 2)
def test_rev_default_not_in_meta(self):
"""@client with default rev=0 does not store rev in meta."""
Ctx = ReactContext("data")
@client(context=Ctx)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertNotIn("rev", meta)
def test_rev_changes_cache_key(self):
"""Different rev values produce different HMAC cache keys."""
from mizan_core.cache.keys import derive_cache_key
key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
self.assertNotEqual(key_v0, key_v1)
def test_rev_in_manifest(self):
"""Manifest includes rev on functions that set it."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx, rev=3)
def versioned_fn(request: HttpRequest, item_id: int) -> dict:
return {}
register(versioned_fn, "versioned_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertEqual(fn_entry["rev"], 3)
def test_rev_not_in_manifest_when_default(self):
"""Manifest omits rev when it's the default (0)."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx)
def default_fn(request: HttpRequest) -> dict:
return {}
register(default_fn, "default_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertNotIn("rev", fn_entry)
class CacheParameterTests(TestCase):
"""Tests for the @client(cache=...) parameter."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_cache_int_stored_in_meta(self):
"""@client(cache=60) stores cache TTL in meta."""
Ctx = ReactContext("data")
@client(context=Ctx, cache=60)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertEqual(meta["cache"], 60)
def test_cache_false_stored_in_meta(self):
"""@client(cache=False) stores False in meta."""
Ctx = ReactContext("data")
@client(context=Ctx, cache=False)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertIs(meta["cache"], False)
def test_cache_default_not_in_meta(self):
"""Default cache=True is not stored in meta."""
Ctx = ReactContext("data")
@client(context=Ctx)
def my_fn(request: HttpRequest) -> dict:
return {}
register(my_fn, "my_fn")
meta = getattr(get_function("my_fn"), "_meta", {})
self.assertNotIn("cache", meta)
def test_cache_in_manifest(self):
"""Manifest includes cache TTL on functions that set it."""
from mizan.export import generate_edge_manifest
Ctx = ReactContext("data")
@client(context=Ctx, cache=120)
def ttl_fn(request: HttpRequest) -> dict:
return {}
register(ttl_fn, "ttl_fn")
manifest = generate_edge_manifest()
fn_entry = manifest["contexts"]["data"]["functions"][0]
self.assertEqual(fn_entry["cache"], 120)
class CachePolicyIntegrationTests(TestCase):
"""Tests for effective cache policy resolution in context_fetch_view."""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_cache_int_still_no_store_header(self):
"""cache=60 affects origin Redis TTL, but HTTP header is always no-store."""
Ctx = ReactContext("trending")
@client(context=Ctx, cache=60)
def trending(request: HttpRequest) -> dict:
return {"items": []}
register(trending, "trending")
response = self.client.get("/api/mizan/ctx/trending/")
self.assertEqual(response["Cache-Control"], "no-store")
def test_cache_false_no_store(self):
"""Context with cache=False emits no-store."""
Ctx = ReactContext("random")
@client(context=Ctx, cache=False)
def random_fn(request: HttpRequest) -> dict:
return {"value": 42}
register(random_fn, "random_fn")
response = self.client.get("/api/mizan/ctx/random/")
self.assertEqual(response["Cache-Control"], "no-store")
def test_effective_rev_is_maximum(self):
"""Context with mixed revs uses the maximum for cache key."""
from mizan_core.cache.keys import derive_cache_key
from mizan.cache import set_cache, reset_cache
from mizan_core.cache.backend import MemoryCache
from mizan.setup.settings import clear_settings_cache
from django.test import override_settings
Ctx = ReactContext("versioned")
@client(context=Ctx, rev=0)
def old_fn(request: HttpRequest, item_id: int) -> dict:
return {"old": True}
@client(context=Ctx, rev=2)
def new_fn(request: HttpRequest, item_id: int) -> dict:
return {"new": True}
register(old_fn, "old_fn")
register(new_fn, "new_fn")
mem_cache = MemoryCache()
set_cache(mem_cache)
with override_settings(MIZAN_CACHE_SECRET="test", MIZAN_CACHE_REDIS_URL="dummy"):
clear_settings_cache()
r1 = self.client.get("/api/mizan/ctx/versioned/?item_id=1")
self.assertEqual(r1.status_code, 200)
# The cache key should use rev=2 (max)
expected_key = derive_cache_key("test", "versioned", {"item_id": "1"}, rev=2)
self.assertIn(expected_key, mem_cache._store)
reset_cache()
clear_settings_cache()
# ── MWT (Mizan Web Token) tests ────────────────────────────────────────────
class MWTAuthIntegrationTests(TestCase):
"""Tests for MWT authentication in the executor."""
SECRET = "test-mwt-auth-secret-thats-32bytes!" # 32+ bytes for HS256
def setUp(self):
clear_registry()
self.factory = RequestFactory()
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
UserCtx = ReactContext("user")
@client(context=UserCtx, auth=True)
def protected_fn(request: HttpRequest, user_id: int) -> dict:
return {"viewer": request.user.pk}
register(protected_fn, "protected_fn")
def tearDown(self):
clear_registry()
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
def test_mwt_auth_via_header(self):
"""Request with valid X-Mizan-Token authenticates."""
from mizan_core.mwt import create_mwt
from mizan.client.executor import _try_mwt_auth
from django.test import override_settings
user = MagicMock()
user.pk = 5
user.is_staff = False
user.is_superuser = False
user.get_all_permissions = MagicMock(return_value=set())
token = create_mwt(user, self.SECRET)
request = self.factory.get("/")
request.META["HTTP_X_MIZAN_TOKEN"] = token
request.user = MagicMock(is_authenticated=False)
with override_settings(MIZAN_MWT_SECRET=self.SECRET):
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
result = _try_mwt_auth(request)
self.assertTrue(result)
self.assertEqual(request.user.pk, 5)
self.assertTrue(request.user.is_authenticated)
def test_mwt_invalid_returns_401(self):
"""Invalid X-Mizan-Token returns 401 on context fetch."""
from django.test import override_settings
with override_settings(MIZAN_MWT_SECRET=self.SECRET):
from mizan.setup.settings import clear_settings_cache
clear_settings_cache()
response = self.client.get(
"/api/mizan/ctx/user/?user_id=5",
HTTP_X_MIZAN_TOKEN="invalid-token",
)
self.assertEqual(response.status_code, 401)
def test_legacy_jwt_still_works(self):
"""Authorization: Bearer still accepted alongside MWT."""
from mizan.jwt.tokens import create_token_pair
from tests.models import EmailUser
user = EmailUser.objects.create_user(email="legacy@test.com", password="pass")
self.client.login(email="legacy@test.com", password="pass")
session_key = self.client.session.session_key
tokens = create_token_pair(
user.pk, session_key,
is_staff=False, is_superuser=False,
)
response = self.client.get(
"/api/mizan/ctx/user/?user_id=5",
HTTP_AUTHORIZATION=f"Bearer {tokens.access_token}",
)
self.assertEqual(response.status_code, 200)
# ── Redis backend tests (requires running Redis on port 6399) ──────────
import os
REDIS_URL = os.environ.get("MIZAN_TEST_REDIS_URL", "redis://localhost:6399/0")
def _redis_available() -> bool:
"""Check if a test Redis instance is reachable."""
try:
import redis
client = redis.from_url(REDIS_URL, socket_connect_timeout=1)
client.ping()
return True
except Exception:
return False
_SKIP_REDIS = not _redis_available()
_SKIP_MSG = f"Redis not available at {REDIS_URL}"
class RedisCacheBackendTests(TestCase):
"""Tests for RedisCache against a real Redis instance."""
SECRET = "test-cache-secret-for-redis-32b!"
def setUp(self):
if _SKIP_REDIS:
self.skipTest(_SKIP_MSG)
from mizan_core.cache.backend import RedisCache
self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
self.cache.clear()
def tearDown(self):
if not _SKIP_REDIS:
self.cache.clear()
def test_get_miss(self):
"""Empty cache returns None."""
self.assertIsNone(self.cache.get("nonexistent"))
def test_set_then_get(self):
"""Store and retrieve a value."""
self.cache.set("key1", b'{"data": true}')
result = self.cache.get("key1")
self.assertEqual(result, b'{"data": true}')
def test_delete(self):
"""Delete a key."""
self.cache.set("k1", b"v1")
self.assertTrue(self.cache.delete("k1"))
self.assertIsNone(self.cache.get("k1"))
def test_delete_nonexistent(self):
"""Delete a nonexistent key returns False."""
self.assertFalse(self.cache.delete("ghost"))
def test_delete_by_prefix(self):
"""Delete all keys matching a prefix."""
self.cache.set("ctx:user:abc", b"v1")
self.cache.set("ctx:user:def", b"v2")
self.cache.set("ctx:products:ghi", b"v3")
count = self.cache.delete_by_prefix("ctx:user:")
self.assertEqual(count, 2)
self.assertIsNone(self.cache.get("ctx:user:abc"))
self.assertIsNone(self.cache.get("ctx:user:def"))
self.assertIsNotNone(self.cache.get("ctx:products:ghi"))
def test_ttl_is_set(self):
"""Set applies a TTL on the cache key."""
import redis
self.cache.set("ttl_key", b"value")
client = redis.from_url(REDIS_URL)
ttl = client.ttl("mizan:test:ttl_key")
client.close()
self.assertGreater(ttl, 0)
self.assertLessEqual(ttl, self.cache._ttl)
def test_clear(self):
"""Clear removes all keys with our prefix."""
self.cache.set("k1", b"v1")
self.cache.set("k2", b"v2")
self.cache.clear()
self.assertIsNone(self.cache.get("k1"))
self.assertIsNone(self.cache.get("k2"))
def test_clear_preserves_other_prefixes(self):
"""Clear only removes keys with our prefix, not others."""
import redis
client = redis.from_url(REDIS_URL)
client.set("other:key", "should_survive")
self.cache.set("k1", b"v1")
self.cache.clear()
self.assertIsNone(self.cache.get("k1"))
self.assertEqual(client.get("other:key"), b"should_survive")
client.delete("other:key")
client.close()
class RedisCachePurgeTests(TestCase):
"""Tests for cache_purge against real Redis."""
SECRET = "test-cache-secret-for-redis-32b!"
def setUp(self):
if _SKIP_REDIS:
self.skipTest(_SKIP_MSG)
from mizan_core.cache.backend import RedisCache
from mizan.cache import cache_put
self.cache = RedisCache(REDIS_URL, prefix="mizan:test:")
self.cache.clear()
cache_put(self.SECRET, self.cache, "user", {"user_id": "5"}, b'{"u5":true}')
cache_put(self.SECRET, self.cache, "user", {"user_id": "6"}, b'{"u6":true}')
def tearDown(self):
if not _SKIP_REDIS:
self.cache.clear()
def test_scoped_purge(self):
"""Scoped purge recomputes key and deletes directly."""
from mizan.cache import cache_purge, cache_get
count = cache_purge(
self.cache, "user", {"user_id": "5"}, secret=self.SECRET,
)
self.assertEqual(count, 1)
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
self.assertIsNotNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))
def test_broad_purge(self):
"""Broad purge uses prefix scan to remove all entries in context."""
from mizan.cache import cache_purge, cache_get
count = cache_purge(self.cache, "user")
self.assertEqual(count, 2)
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "5"}))
self.assertIsNone(cache_get(self.SECRET, self.cache, "user", {"user_id": "6"}))

View File

@@ -42,7 +42,7 @@ from mizan.client.executor import (
FunctionResult,
execute_function,
)
from mizan.setup.registry import clear_registry, get_function, register
from mizan_core.registry import clear_registry, get_function, register
from mizan.client import ServerFunction, client
@@ -1179,7 +1179,7 @@ class RegistrationSecurityTests(TestCase):
But a DIFFERENT function cannot take over an existing name.
"""
from mizan.client import ServerFunction
from mizan.setup.registry import register
from mizan_core.registry import register
# Register first function
class OriginalFunc(ServerFunction):

View File

@@ -29,7 +29,7 @@ from mizan.client.executor import (
execute_function,
function_call_view,
)
from mizan.setup.registry import clear_registry, register, register_as, get_function
from mizan_core.registry import clear_registry, register, register_as, get_function
from mizan.client import ServerFunction, client
from mizan.channels import ReactChannel

View File

@@ -0,0 +1,162 @@
"""
Tests for the Mizan SSR bridge and template backend.
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
Tests skip gracefully if Bun is not available.
"""
import os
import shutil
import threading
from django.test import SimpleTestCase, RequestFactory
# Path to the test worker
_SSR_WORKER = os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "..", "..", # up to repo root
"packages", "mizan-ssr", "src", "test-worker.tsx",
)
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
_BUN_AVAILABLE = shutil.which("bun") is not None
_SKIP_MSG = "Bun not available"
class SSRBridgeTests(SimpleTestCase):
"""Tests for the SSR bridge subprocess manager."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.bridge import SSRBridge
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
def tearDown(self):
if hasattr(self, "bridge"):
self.bridge.shutdown()
def test_ping(self):
"""Worker starts and responds to ping."""
self.assertTrue(self.bridge.ping())
def test_render_simple(self):
"""Renders a simple component to HTML."""
result = self.bridge.render("Hello", {"name": "World"})
self.assertIn("Hello,", result.html)
self.assertIn("World", result.html)
def test_render_with_props(self):
"""Renders a component with multiple props."""
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
self.assertIn("Alice", result.html)
self.assertIn("42", result.html)
def test_render_missing_component(self):
"""Rendering an unregistered component raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("NonExistent", {})
self.assertIn("not registered", str(ctx.exception))
def test_render_error(self):
"""Component that throws during render raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("Broken", {})
self.assertIn("Render error", str(ctx.exception))
def test_crash_recovery(self):
"""Bridge restarts the worker if it dies."""
# First render works
result = self.bridge.render("Hello", {"name": "Before"})
self.assertIn("Before", result.html)
# Kill the subprocess
self.bridge._proc.kill()
self.bridge._proc.wait()
# Next render should restart and work
result = self.bridge.render("Hello", {"name": "After"})
self.assertIn("After", result.html)
def test_concurrent_renders(self):
"""Multiple threads can render simultaneously."""
results = {}
errors = {}
def render_in_thread(name: str, idx: int):
try:
result = self.bridge.render("Hello", {"name": name})
results[idx] = result.html
except Exception as e:
errors[idx] = e
threads = []
for i in range(5):
t = threading.Thread(target=render_in_thread, args=(f"User{i}", i))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
self.assertEqual(len(results), 5)
for i in range(5):
self.assertIn(f"User{i}", results[i])
class SSRTemplateBackendTests(SimpleTestCase):
"""Tests for the MizanTemplates Django template backend."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.backend import MizanTemplates
self.engine = MizanTemplates({
"NAME": "mizan-test",
"DIRS": [],
"APP_DIRS": False,
"OPTIONS": {
"worker_path": _SSR_WORKER,
"timeout": 5,
},
})
self.factory = RequestFactory()
def tearDown(self):
if hasattr(self, "engine") and self.engine._bridge is not None:
self.engine._bridge.shutdown()
def test_get_template(self):
"""get_template returns a MizanTemplate."""
from mizan.ssr.backend import MizanTemplate
template = self.engine.get_template("Hello")
self.assertIsInstance(template, MizanTemplate)
self.assertEqual(template.component_name, "Hello")
def test_template_render(self):
"""MizanTemplate.render() produces HTML."""
template = self.engine.get_template("Hello")
html = template.render({"name": "Django"})
self.assertIn("Hello,", html)
self.assertIn("Django", html)
self.assertIn('data-mizan-component="Hello"', html)
def test_template_render_strips_django_internals(self):
"""Django-internal context keys (request, csrf_token) are not passed as props."""
template = self.engine.get_template("Hello")
request = self.factory.get("/")
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
self.assertIn("Test", html)
def test_from_string_raises(self):
"""from_string is not supported."""
from django.template import TemplateDoesNotExist
with self.assertRaises(TemplateDoesNotExist):
self.engine.from_string("<div>Not supported</div>")

View File

@@ -0,0 +1,193 @@
# mizan-fastapi
FastAPI backend adapter for the Mizan protocol. One decorator on a server
function. Typed React client generated. Invalidation automatic.
## Scope
mizan-fastapi targets the **AFI-common subset** — RPC dispatch, context
bundling, JSON-body invalidation, and auth gating. Forms, Channels, Shapes,
and SSR are out of scope for the FastAPI adapter — FastAPI projects use
native equivalents (Pydantic, native WebSockets, ORM-of-choice, FastAPI's
own SSR ecosystem).
## Install
```bash
uv add mizan-fastapi
```
## Setup
```python
# main.py
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
```
The exception handlers render every error path through the Mizan envelope
(`{"error": {"code", "message", "details"}}`) so the kernel's `MizanError`
parses status + code on the frontend regardless of which failure happened.
## Define server functions
```python
from mizan_core.client.function import client
from mizan_core.registry import register
from pydantic import BaseModel
class EchoOutput(BaseModel):
message: str
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=text)
register(echo, "echo")
```
mizan-fastapi has no auto-discovery (FastAPI doesn't have an app registry
to walk). Register every `@client`-decorated function explicitly. A typical
project keeps registrations in `main.py` (alongside the FastAPI app) or in
a dedicated `clients.py` imported during startup.
## `@client` parameters
```python
@client # plain RPC function
@client(context="global") # singleton context — fetched once, SSR-hydrated
@client(context="user") # named context — fetched per provider mount
@client(affects="user") # mutation — invalidates the user context
@client(affects=user_profile) # mutation — invalidates a specific function
@client(auth=True) # requires authentication
@client(auth="staff") # requires is_staff
@client(auth="superuser") # requires is_superuser
@client(auth=lambda req: ...) # custom predicate
@client(rev=2) # cache revision (busts on bump)
```
`websocket=True`, Forms, and Channels parameters are accepted by the
decorator (they're a `mizan-core` primitive) but ignored by mizan-fastapi —
those features only have effect when paired with mizan-django.
## Auth integration
The executor expects `request.state.user` to be populated by your FastAPI
middleware or dependency tree before dispatch:
```python
from fastapi import Request
@app.middleware("http")
async def attach_user(request: Request, call_next):
request.state.user = await resolve_user_from_token(request)
return await call_next(request)
```
Where `resolve_user_from_token` returns either a user object with
`is_authenticated`, `is_staff`, `is_superuser` attributes, or `None` for an
anonymous request. The executor branches on those for `auth=True`,
`auth="staff"`, `auth="superuser"` requirements.
## Generate the frontend
The codegen is `mizan-generate` (in `protocol/mizan-generate/`). Point a
config at your FastAPI app and run the CLI:
```js
// frontend/fastapi.config.mjs
import path from "path"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
export default {
source: {
fastapi: {
module: "main", // module to import for @client side effects
cwd: path.join(root, "backend"), // python cwd for module resolution
command: ["uv", "run", "python"], // optional — defaults to ["python"]
},
},
output: "src/api",
}
```
```bash
npx mizan-generate --config fastapi.config.mjs
```
The codegen drives `python -m mizan_fastapi.cli <module>` under the hood,
then emits Stage 1 (typed `callXxx`/`fetchXxx` over the runtime kernel) +
Stage 2 (`<MizanContext>` provider, per-context providers, `use{Hook}()`
hooks) into `src/api/`.
```tsx
// app.tsx
import { MizanContext } from "./api"
export default function App({ children }) {
return <MizanContext baseUrl="/api/mizan">{children}</MizanContext>
}
```
```tsx
// any component
import { useEcho, useCurrentUser } from "./api"
const echo = useEcho()
echo.mutate({ text: "hi" }).then(r => console.log(r.message))
const user = useCurrentUser() // global context — auto-fetched, auto-refreshed on mutation
```
## Running tests
```bash
uv sync --extra dev
uv run pytest
```
## Schema export CLI
For codegen consumption (or any tooling that wants the Mizan schema):
```bash
python -m mizan_fastapi.cli <module>
```
Imports the named module (which must register every `@client` function as
import-time side effects), then prints the OpenAPI schema as JSON to stdout.
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
consumes either backend the same subprocess way.
## Architecture
mizan-fastapi is one of two reference backend adapters (the other is
`backends/mizan-django`). Both implement the same Mizan protocol on top of
the shared `cores/mizan-python` core (`@client`, registry, MWT, HMAC cache
keys). The AFI conformance suite at `tests/afi/` gates that the two adapters
emit equivalent schemas for the same registered functions. See
`docs/AFI_ARCHITECTURE.md`.
A live e2e harness exercises this adapter end-to-end at
`examples/fastapi-react-site/` (real Chromium → React with generated hooks
→ FastAPI server, 14/14 Playwright tests).

View File

@@ -0,0 +1,32 @@
[project]
name = "mizan-fastapi"
version = "0.1.0"
description = "Mizan FastAPI backend adapter — HTTP RPC dispatch + context bundling, built on mizan-core."
requires-python = ">=3.10"
dependencies = [
"mizan-core",
"fastapi>=0.110",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mizan_fastapi"]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -0,0 +1,56 @@
"""
mizan-fastapi — FastAPI backend adapter for the Mizan protocol.
HTTP RPC dispatch and context bundling on top of mizan-core's function
registry. Channels, Forms, Shapes, SSR are out of scope — FastAPI
projects use native equivalents (WebSocket, Pydantic, ORM-of-choice,
SSR frameworks).
Usage:
from fastapi import FastAPI
from mizan_fastapi import router, mizan_exception_handler, MizanError
app = FastAPI()
app.include_router(router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
# Register your @client-decorated functions
from mizan_core.client.function import client
from mizan_core.registry import register
from .my_functions import echo
register(echo, "echo")
"""
from .executor import (
ErrorCode,
MizanError,
NotFound,
BadRequest,
ValidationFailed,
Unauthorized,
Forbidden,
NotImplementedYet,
InternalError,
compute_invalidation,
execute_function,
)
from .router import router, mizan_exception_handler, mizan_validation_handler
from .schema import build_schema
__all__ = [
"router",
"mizan_exception_handler",
"mizan_validation_handler",
"execute_function",
"compute_invalidation",
"build_schema",
"ErrorCode",
"MizanError",
"NotFound",
"BadRequest",
"ValidationFailed",
"Unauthorized",
"Forbidden",
"NotImplementedYet",
"InternalError",
]

View File

@@ -0,0 +1,45 @@
"""
Schema-export CLI for codegen consumption.
Usage:
python -m mizan_fastapi.cli <module>
Imports the named module (whose import side effects must register every
@client function with mizan_core.registry — typically by `@client` plus
`register(...)` calls at module top level), then prints the OpenAPI
schema to stdout as JSON.
Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen
CLI can fetch from either backend the same subprocess way.
"""
from __future__ import annotations
import importlib
import json
import sys
from .schema import build_schema
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if len(args) != 1:
print("usage: python -m mizan_fastapi.cli <module>", file=sys.stderr)
return 2
module_name = args[0]
try:
importlib.import_module(module_name)
except Exception as e:
print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
return 1
schema = build_schema()
json.dump(schema, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,197 @@
"""
RPC dispatch — looks up registered functions, validates input against the
function's Pydantic Input model, executes, and returns the serialized result.
Errors raise typed exceptions (MizanError subclasses). Wire those to JSON
responses by registering `mizan_exception_handler` on the FastAPI app, or
let them propagate to your own handler.
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, ValidationError
from mizan_core.registry import get_function
# ─── Error taxonomy ─────────────────────────────────────────────────────────
class ErrorCode(str, Enum):
NOT_FOUND = "NOT_FOUND"
BAD_REQUEST = "BAD_REQUEST"
VALIDATION_ERROR = "VALIDATION_ERROR"
UNAUTHORIZED = "UNAUTHORIZED"
FORBIDDEN = "FORBIDDEN"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
INTERNAL_ERROR = "INTERNAL_ERROR"
_STATUS = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_ERROR: 422,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.FORBIDDEN: 403,
ErrorCode.NOT_IMPLEMENTED: 501,
ErrorCode.INTERNAL_ERROR: 500,
}
class MizanError(Exception):
"""Base for protocol-level dispatch errors."""
code: ErrorCode = ErrorCode.INTERNAL_ERROR
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = details
@property
def status_code(self) -> int:
return _STATUS[self.code]
class NotFound(MizanError): code = ErrorCode.NOT_FOUND # noqa: E701
class BadRequest(MizanError): code = ErrorCode.BAD_REQUEST # noqa: E701
class ValidationFailed(MizanError): code = ErrorCode.VALIDATION_ERROR # noqa: E701
class Unauthorized(MizanError): code = ErrorCode.UNAUTHORIZED # noqa: E701
class Forbidden(MizanError): code = ErrorCode.FORBIDDEN # noqa: E701
class NotImplementedYet(MizanError): code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
class InternalError(MizanError): code = ErrorCode.INTERNAL_ERROR # noqa: E701
# ─── Auth ───────────────────────────────────────────────────────────────────
def _user(request: Any) -> Any:
return getattr(getattr(request, "state", None), "user", None)
def _is_authenticated(user: Any) -> bool:
return bool(user) and getattr(user, "is_authenticated", True)
def _enforce_auth(request: Any, requirement: Any) -> None:
"""Verify the request meets the function's @client(auth=...) requirement, or raise."""
if requirement is None:
return
user = _user(request)
match requirement:
case True | "required":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
case "staff":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_staff", False):
raise Forbidden("Staff access required")
case "superuser":
if not _is_authenticated(user):
raise Unauthorized("Authentication required")
if not getattr(user, "is_superuser", False):
raise Forbidden("Superuser access required")
case f if callable(f):
if not f(request):
raise Forbidden("Permission denied")
case other:
raise InternalError(f"Unknown auth requirement: {other!r}")
# ─── Input validation ───────────────────────────────────────────────────────
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
"""Validate input_data against the function's Input model. Returns the instance or None."""
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
return None
fields = input_cls.model_fields
required = [name for name, f in fields.items() if f.is_required()]
if not input_data:
if required:
raise ValidationFailed(
"Input validation failed",
details={"fields": {name: ["Field required"] for name in required}},
)
return input_cls()
if not isinstance(input_data, dict):
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
try:
return input_cls(**input_data)
except ValidationError as e:
raise ValidationFailed(
"Input validation failed",
details={"errors": e.errors()},
) from e
# ─── Dispatch ───────────────────────────────────────────────────────────────
def _resolve_function(fn_name: str) -> Any:
view_class = get_function(fn_name)
if view_class is None:
raise NotFound("Function not found")
if getattr(view_class, "_meta", {}).get("private"):
raise Forbidden("Function is not client-callable")
return view_class
def _serialize(result: Any) -> Any:
return result.model_dump(mode="json") if isinstance(result, BaseModel) else result
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."""
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 = view.call(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]:
"""Build the `invalidate` list from @client(affects=...) metadata, auto-scoping when arg names match context params."""
affects = getattr(view_class, "_meta", {}).get("affects") or []
return [_invalidation_target(target, input_data or {}) for target in affects]
def _invalidation_target(target: dict[str, Any], input_data: dict[str, Any]) -> Any:
match target.get("type"):
case "context":
name = target["name"]
scope_keys = (target.get("params") or {}).keys()
scoped = {k: input_data[k] for k in scope_keys if k in input_data}
return {"context": name, "params": scoped} if scoped else name
case "function":
return {"function": target["name"]}
case _:
return target

View File

@@ -0,0 +1,92 @@
"""
FastAPI router exposing Mizan's HTTP endpoints:
POST /call/ — RPC dispatch
GET /ctx/{context_name}/ — bundled context fetch
from fastapi import FastAPI
from mizan_fastapi import router, mizan_exception_handler, MizanError
app = FastAPI()
app.include_router(router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from mizan_core.registry import get_context_groups, get_function
from .executor import (
ErrorCode,
MizanError,
NotFound,
compute_invalidation,
execute_function,
)
router = APIRouter()
def _no_store(payload: Any, status_code: int = 200) -> JSONResponse:
return JSONResponse(payload, status_code=status_code, headers={"Cache-Control": "no-store"})
# ─── Endpoints ──────────────────────────────────────────────────────────────
class CallBody(BaseModel):
fn: str = Field(..., min_length=1)
args: dict[str, Any] = Field(default_factory=dict)
@router.post("/call/")
async def function_call(body: CallBody, request: Request) -> JSONResponse:
"""RPC dispatch — `{"fn": "...", "args": {...}}` → `{"result": ..., "invalidate": [...]}`."""
result = execute_function(request, body.fn, body.args)
invalidate = compute_invalidation(get_function(body.fn), body.args)
return _no_store({"result": result, "invalidate": invalidate})
@router.get("/ctx/{context_name}/")
async def context_fetch(context_name: str, request: Request) -> JSONResponse:
"""Bundled context fetch — `{function_name: result, ...}` for every function in the context."""
fn_names = get_context_groups().get(context_name)
if not fn_names:
raise NotFound(f"Context '{context_name}' not found")
params = dict(request.query_params)
bundled = {fn: execute_function(request, fn, params) for fn in fn_names}
return _no_store(bundled)
# ─── Exception handler ──────────────────────────────────────────────────────
async def mizan_exception_handler(_request: Request, exc: MizanError) -> JSONResponse:
"""FastAPI exception handler — renders MizanError to the protocol's error envelope."""
body: dict[str, Any] = {"error": {"code": exc.code.value, "message": exc.message}}
if exc.details:
body["error"]["details"] = exc.details
return _no_store(body, status_code=exc.status_code)
async def mizan_validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
"""Maps malformed request bodies (invalid JSON, missing top-level fields) to BAD_REQUEST."""
return _no_store(
{
"error": {
"code": ErrorCode.BAD_REQUEST.value,
"message": "Invalid request body",
"details": {"errors": exc.errors()},
}
},
status_code=400,
)

View File

@@ -0,0 +1,209 @@
"""
Mizan schema export for FastAPI backends.
Builds an OpenAPI 3.0 document from the registered Mizan functions, mirroring
the shape mizan-django emits via Django Ninja so the codegen consumes either
backend identically.
Usage:
from mizan_fastapi.schema import build_schema
schema = build_schema() # uses globally registered functions
"""
from __future__ import annotations
import re
from typing import Any
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic import BaseModel, create_model
from mizan_core.registry import get_all_functions, get_context_groups, get_function
__all__ = ["build_schema", "snake_to_camel"]
# Common user identity param names — mirrors mizan-django's _USER_SCOPED_PARAMS
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
def snake_to_camel(name: str) -> str:
"""Convert snake_case or dotted.name to camelCase. Mirrors mizan-django."""
components = re.split(r"[._]", name)
return components[0] + "".join(c.title() for c in components[1:])
def _has_input(input_cls: Any) -> bool:
return (
input_cls is not None
and input_cls is not BaseModel
and hasattr(input_cls, "model_fields")
and bool(input_cls.model_fields)
)
def _annotation_to_jsonschema_type(annotation: Any) -> str:
if annotation is int:
return "integer"
if annotation is float:
return "number"
if annotation is bool:
return "boolean"
return "string"
def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]:
"""Build one entry of x-mizan-functions. Mirrors Django's shape exactly."""
camel = snake_to_camel(name)
meta = getattr(fn_class, "_meta", {})
input_cls = getattr(fn_class, "Input", None)
has_input = _has_input(input_cls)
entry: dict[str, Any] = {
"name": name,
"camelName": camel,
"hasInput": has_input,
"inputType": f"{camel}Input" if has_input else None,
"outputType": f"{camel}Output",
"transport": "websocket" if meta.get("websocket") else "http",
"isContext": meta.get("context", False),
# Form metadata — always emitted so the schema shape matches Django's,
# even for FastAPI projects that don't use forms (these stay False/None).
"isForm": meta.get("form", False),
"formName": meta.get("form_name"),
"formRole": meta.get("form_role"),
}
if meta.get("affects"):
entry["affects"] = meta["affects"]
return entry
def _context_metadata(context_groups: dict[str, list[str]]) -> dict[str, Any]:
"""Build x-mizan-contexts. Mirrors Django's param-elevation logic."""
out: dict[str, Any] = {}
for ctx_name, fn_names in context_groups.items():
param_info: dict[str, dict[str, Any]] = {}
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 not _has_input(input_cls):
continue
for field_name, field_info in input_cls.model_fields.items():
if field_name not in param_info:
param_info[field_name] = {
"type": _annotation_to_jsonschema_type(field_info.annotation),
"sharedBy": [],
}
param_info[field_name]["sharedBy"].append(fn_name)
# A param is required iff every function in the context declares it.
for p_meta in param_info.values():
p_meta["required"] = len(p_meta["sharedBy"]) == len(fn_names)
out[ctx_name] = {
"functions": list(fn_names),
"params": param_info,
}
return out
def build_schema() -> dict[str, Any]:
"""
Build an OpenAPI 3.0 schema for all registered Mizan functions.
Drives FastAPI's native OpenAPI generation by registering a stub endpoint
per function with the function's Input/Output Pydantic models, then
appends the protocol's `x-mizan-functions` and `x-mizan-contexts`
extensions.
Returns a dict in the same shape mizan-django's schema export emits, so
the same codegen pipeline consumes either.
"""
functions = get_all_functions()
context_groups = get_context_groups()
schema_app = FastAPI(
title="mizan Server Functions",
version="1.0.0",
description="Auto-generated schema for mizan server functions",
)
# Per-function endpoints + renamed Pydantic models so component names are
# camelCase + "Input"/"Output" rather than the user's original class names.
schema_classes: dict[str, type[BaseModel]] = {}
function_metadata: list[dict[str, Any]] = []
for name, fn_class in functions.items():
camel = snake_to_camel(name)
input_cls = getattr(fn_class, "Input", None)
output_cls = getattr(fn_class, "Output", None) or BaseModel
has_input = _has_input(input_cls)
input_type_name = f"{camel}Input" if has_input else None
output_type_name = f"{camel}Output"
if has_input:
schema_classes[input_type_name] = create_model(
input_type_name, __base__=input_cls,
)
schema_classes[output_type_name] = create_model(
output_type_name, __base__=output_cls,
)
# Stub endpoint — only exists so FastAPI walks Pydantic types into
# components.schemas. Never invoked. Annotations are set explicitly
# rather than via closures so forward-ref resolution doesn't trip on
# locally-bound type names.
if has_input:
async def stub(payload):
return None
stub.__annotations__ = {"payload": schema_classes[input_type_name]}
else:
async def stub():
return None
schema_app.post(
f"/mizan/{name}",
response_model=schema_classes[output_type_name],
operation_id=camel,
summary=fn_class.__doc__ or f"Call {name}",
)(stub)
function_metadata.append(_function_metadata(name, fn_class))
schema = get_openapi(
title=schema_app.title,
version=schema_app.version,
description=schema_app.description,
routes=schema_app.routes,
)
schema["x-mizan-functions"] = function_metadata
if context_groups:
schema["x-mizan-contexts"] = _context_metadata(context_groups)
# Attach x-mizan operation metadata, mirroring Django.
paths = schema.get("paths", {})
for fn_meta in function_metadata:
op = paths.get(f"/mizan/{fn_meta['name']}", {}).get("post")
if op is not None:
op["x-mizan"] = {
"transport": fn_meta["transport"],
"isContext": fn_meta["isContext"],
}
return schema

View File

View File

@@ -0,0 +1,173 @@
"""End-to-end dispatch tests against a real FastAPI app + TestClient."""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import clear_registry, register
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
# ─── Fixtures ───────────────────────────────────────────────────────────────
class EchoOutput(BaseModel):
message: str
class SumOutput(BaseModel):
total: int
class UserOutput(BaseModel):
email: str
authenticated: bool
@pytest.fixture
def app():
"""Build a fresh FastAPI app + Mizan router with a few @client functions."""
clear_registry()
@client
def echo(request, text: str) -> EchoOutput:
return EchoOutput(message=f"echo: {text}")
@client
def add(request, a: int, b: int) -> SumOutput:
return SumOutput(total=a + b)
@client(context="user")
def current_user(request) -> UserOutput:
return UserOutput(email="anon@example.com", authenticated=False)
@client(context="user")
def user_count(request) -> SumOutput:
return SumOutput(total=42)
@client(affects="user")
def update_email(request, email: str) -> EchoOutput:
return EchoOutput(message=f"updated: {email}")
@client(auth=True)
def whoami(request) -> UserOutput:
return UserOutput(email="real@example.com", authenticated=True)
register(echo, "echo")
register(add, "add")
register(current_user, "current_user")
register(user_count, "user_count")
register(update_email, "update_email")
register(whoami, "whoami")
fastapi_app = FastAPI()
fastapi_app.include_router(mizan_router, prefix="/api/mizan")
fastapi_app.add_exception_handler(MizanError, mizan_exception_handler)
fastapi_app.add_exception_handler(RequestValidationError, mizan_validation_handler)
yield fastapi_app
clear_registry()
@pytest.fixture
def http(app):
return TestClient(app)
# ─── RPC dispatch ───────────────────────────────────────────────────────────
class FunctionCallTests:
def test_simple_call_returns_result(self, http):
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}})
assert r.status_code == 200
body = r.json()
assert body["result"]["message"] == "echo: hi"
assert body["invalidate"] == []
def test_call_with_typed_input(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}})
assert r.status_code == 200
assert r.json()["result"]["total"] == 5
def test_unknown_function_returns_not_found(self, http):
r = http.post("/api/mizan/call/", json={"fn": "ghost"})
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_validation_error_returns_422(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}})
assert r.status_code == 422
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
def test_missing_required_input_returns_validation_error(self, http):
r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}})
assert r.status_code == 422
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
def test_missing_fn_field_returns_400(self, http):
r = http.post("/api/mizan/call/", json={})
assert r.status_code == 400
assert r.json()["error"]["code"] == "BAD_REQUEST"
def test_invalid_json_returns_400(self, http):
r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"})
assert r.status_code == 400
def test_response_carries_no_store(self, http):
r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}})
assert r.headers.get("cache-control") == "no-store"
# ─── Context bundling ───────────────────────────────────────────────────────
class ContextFetchTests:
def test_context_returns_bundled_results(self, http):
r = http.get("/api/mizan/ctx/user/")
assert r.status_code == 200
body = r.json()
assert "current_user" in body
assert "user_count" in body
assert body["current_user"]["email"] == "anon@example.com"
assert body["user_count"]["total"] == 42
def test_unknown_context_returns_not_found(self, http):
r = http.get("/api/mizan/ctx/ghost/")
assert r.status_code == 404
assert r.json()["error"]["code"] == "NOT_FOUND"
# ─── Invalidation ───────────────────────────────────────────────────────────
class AuthTests:
"""The decorator normalizes auth=True → meta['auth']='required'; executor must match both."""
def test_anonymous_request_to_auth_required_returns_401(self, http):
r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}})
assert r.status_code == 401
assert r.json()["error"]["code"] == "UNAUTHORIZED"
class InvalidationTests:
def test_mutation_emits_invalidate_list(self, http):
r = http.post(
"/api/mizan/call/",
json={"fn": "update_email", "args": {"email": "new@example.com"}},
)
assert r.status_code == 200
body = r.json()
# affects='user' is a context-name string → invalidate list contains 'user'
assert "user" in body["invalidate"]

44
backends/mizan-ts/src/cache/backend.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* Cache backends — MemoryCache for testing.
*
* Simple key-value store. No reverse indexes.
*/
export interface CacheBackend {
get(key: string): string | null
set(key: string, value: string): void
delete(key: string): boolean
deleteByPrefix(prefix: string): number
clear(): void
}
export class MemoryCache implements CacheBackend {
private _store = new Map<string, string>()
get(key: string): string | null {
return this._store.get(key) ?? null
}
set(key: string, value: string): void {
this._store.set(key, value)
}
delete(key: string): boolean {
return this._store.delete(key)
}
deleteByPrefix(prefix: string): number {
let count = 0
for (const key of [...this._store.keys()]) {
if (key.startsWith(prefix)) {
this._store.delete(key)
count++
}
}
return count
}
clear(): void {
this._store.clear()
}
}

72
backends/mizan-ts/src/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,72 @@
/**
* mizan cache — TypeScript adapter.
*
* Same protocol as Python's mizan.cache. Cross-language conformance
* verified by pin tests. No reverse indexes — scoped purge recomputes
* the key directly, broad purge uses prefix scan.
*/
export { MemoryCache } from './backend'
export type { CacheBackend } from './backend'
export { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
import type { CacheBackend } from './backend'
import { deriveCacheKey, CONTEXT_KEY_PREFIX } from './keys'
let _cacheInstance: CacheBackend | null = null
export function getCache(): CacheBackend | null {
return _cacheInstance
}
export function setCache(backend: CacheBackend | null): void {
_cacheInstance = backend
}
export function resetCache(): void {
_cacheInstance = null
}
export function cacheGet(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string | null {
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.get(key)
}
export function cachePut(
secret: string,
backend: CacheBackend,
context: string,
params: Record<string, any>,
value: string,
userId?: string,
rev: number = 0,
): void {
const key = deriveCacheKey(secret, context, params, userId, rev)
backend.set(key, value)
}
export function cachePurge(
backend: CacheBackend,
context: string,
params?: Record<string, any> | null,
secret?: string | null,
userId?: string,
rev: number = 0,
): number {
if (params && secret) {
// Scoped purge — recompute key and delete directly
const key = deriveCacheKey(secret, context, params, userId, rev)
return backend.delete(key) ? 1 : 0
} else {
// Broad purge — prefix scan
const prefix = `${CONTEXT_KEY_PREFIX}${context}:`
return backend.deleteByPrefix(prefix)
}
}

57
backends/mizan-ts/src/cache/keys.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
/**
* Cache key derivation — HMAC-SHA256 over JSON-canonical form.
*
* Protocol-critical: must produce identical output to Python's derive_cache_key.
* Cross-language conformance verified by pin tests.
*
* Key format: "ctx:{context}:{hmac_hex}" — enables broad purge by prefix scan.
*/
import { createHmac } from 'crypto'
const CONTEXT_KEY_PREFIX = 'ctx:'
/**
* JSON.stringify with recursively sorted keys and no whitespace.
* Equivalent to Python's json.dumps(obj, sort_keys=True, separators=(",", ":"))
*/
function stableStringify(obj: any): string {
if (obj === null || obj === undefined) return 'null'
if (typeof obj === 'string') return JSON.stringify(obj)
if (typeof obj === 'number' || typeof obj === 'boolean') return String(obj)
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']'
}
const keys = Object.keys(obj).sort()
const pairs = keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k]))
return '{' + pairs.join(',') + '}'
}
/**
* Derive a deterministic HMAC-SHA256 cache key.
*
* Returns "ctx:{context}:{hmac_hex}".
*/
export function deriveCacheKey(
secret: string,
context: string,
params: Record<string, any>,
userId?: string,
rev: number = 0,
): string {
const sortedParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
sortedParams[k] = String(v)
}
const keyData: Record<string, any> = { c: context, p: sortedParams, r: rev }
if (userId !== undefined) {
keyData.u = String(userId)
}
const message = stableStringify(keyData)
const hmacHex = createHmac('sha256', secret).update(message).digest('hex')
return `${CONTEXT_KEY_PREFIX}${context}:${hmacHex}`
}
export { CONTEXT_KEY_PREFIX }

View File

@@ -102,6 +102,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)
@@ -132,6 +134,8 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
route: options.route,
methods: options.methods,
auth: options.auth,
rev: options.rev,
cache: options.cache,
}
register(entry)

View File

@@ -7,6 +7,14 @@
import { getFunction, getContextGroups } from './registry'
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
let _cacheSecret: string | null = null
/** Set the cache secret for origin-side caching. */
export function setCacheSecret(secret: string | null): void {
_cacheSecret = secret
}
export interface MizanResponse {
status: number
@@ -38,6 +46,29 @@ export async function handleContextFetch(
}
}
// Resolve effective rev (max across functions) and cache policy (min TTL)
let effectiveRev = 0
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (entry?.rev) effectiveRev = Math.max(effectiveRev, entry.rev)
}
// Origin-side cache lookup
const cacheBackend = getCache()
const cacheSecret = _cacheSecret
if (cacheBackend && cacheSecret) {
try {
const cached = cacheGet(cacheSecret, cacheBackend, contextName, params, undefined, effectiveRev)
if (cached !== null) {
return {
status: 200,
body: JSON.parse(cached),
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Mizan-Cache': 'HIT' },
}
}
} catch { /* cache miss on error */ }
}
const results: Record<string, any> = {}
for (const fnName of fnNames) {
@@ -67,12 +98,33 @@ export async function handleContextFetch(
}
}
// Resolve effective cache policy for origin-side cache decision
let effectiveCache: number | boolean = true
for (const fnName of fnNames) {
const entry = getFunction(fnName)
if (!entry) continue
if (entry.cache === false) { effectiveCache = false; break }
if (typeof entry.cache === 'number') {
effectiveCache = effectiveCache === true
? entry.cache
: Math.min(effectiveCache as number, entry.cache)
}
}
// Store in origin-side cache (skip if cache=False)
if (cacheBackend && cacheSecret && effectiveCache !== false) {
try {
cachePut(cacheSecret, cacheBackend, contextName, params, JSON.stringify(results), undefined, effectiveRev)
} catch { /* cache store failure is non-fatal */ }
}
return {
status: 200,
body: results,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=0, s-maxage=31536000',
'Cache-Control': 'no-store',
...(cacheBackend && cacheSecret ? { 'X-Mizan-Cache': 'MISS' } : {}),
},
}
}
@@ -134,6 +186,20 @@ export async function handleMutationCall(
if (invalidate) {
responseData.invalidate = invalidate
headers['X-Mizan-Invalidate'] = formatInvalidateHeader(invalidate)
// Purge origin-side cache
const cb = getCache()
if (cb) {
try {
for (const entry of invalidate) {
if (typeof entry === 'string') {
cachePurge(cb, entry)
} else {
cachePurge(cb, entry.context, entry.params, _cacheSecret)
}
}
} catch { /* purge failure is non-fatal */ }
}
}
return { status: 200, body: responseData, headers }

View File

@@ -11,3 +11,7 @@ export type { MizanResponse } from './dispatch'
export { resolveInvalidation, formatInvalidateHeader } from './invalidation'
export { generateManifest } from './manifest'
export { MemoryCache, getCache, setCache, resetCache, cacheGet, cachePut, cachePurge, deriveCacheKey } from './cache'
export type { CacheBackend } from './cache'
export { setCacheSecret } from './dispatch'

View File

@@ -88,7 +88,7 @@ export function formatInvalidateHeader(invalidate: InvalidateEntry[]): string {
const { context, params } = entry
if (params && Object.keys(params).length > 0) {
const paramStr = Object.entries(params)
.sort(([a], [b]) => a.localeCompare(b))
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([k, v]) => `${encodeURIComponent(String(k))}=${encodeURIComponent(String(v))}`)
.join(';')
parts.push(`${context};${paramStr}`)

View File

@@ -35,6 +35,8 @@ export function generateManifest(baseUrl = '/api/mizan'): EdgeManifest {
fnEntry.methods = entry.methods || ['GET']
pageRoutes.push(entry.route)
}
if (entry.rev !== undefined && entry.rev !== 0) fnEntry.rev = entry.rev
if (entry.cache !== undefined && entry.cache !== true) fnEntry.cache = entry.cache
functions.push(fnEntry)
}

View File

@@ -17,6 +17,8 @@ export interface ClientOptions {
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ParamDef {
@@ -36,6 +38,8 @@ export interface RegistryEntry {
route?: string
methods?: string[]
auth?: boolean
rev?: number
cache?: number | false
}
export interface ManifestContext {

View File

@@ -6,7 +6,7 @@
*/
import { describe, test, expect, beforeEach } from 'bun:test'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest } from '../src'
import { ReactContext, client, clearRegistry, handleContextFetch, handleMutationCall, formatInvalidateHeader, generateManifest, MemoryCache, setCache, resetCache, setCacheSecret, deriveCacheKey, cacheGet, cachePut, cachePurge } from '../src'
const UserCtx = new ReactContext('user')
@@ -52,11 +52,9 @@ describe('Edge Compatibility', () => {
// ── Cache-Control correctness ───────────────────────────────────────
test('context GET is cacheable', async () => {
test('context GET emits no-store', async () => {
const r = await handleContextFetch('user', { userId: '5' })
expect(r.headers['Cache-Control']).toContain('public')
expect(r.headers['Cache-Control']).toContain('s-maxage')
expect(r.headers['Cache-Control']).not.toContain('no-store')
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('mutation POST not cacheable', async () => {
@@ -228,4 +226,200 @@ describe('Manifest', () => {
expect(m.mutations.stripeWebhook.route).toBe('/webhooks/stripe/')
expect(m.mutations.stripeWebhook.methods).toEqual(['POST'])
})
test('rev appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('data')
client({ context: Ctx, rev: 3 }, async function versionedFn(itemId: number) {
return { value: itemId }
})
const m = generateManifest()
const fn = m.contexts.data.functions[0]
expect(fn.rev).toBe(3)
})
test('cache TTL appears in manifest', () => {
clearRegistry()
const Ctx = new ReactContext('trending')
client({ context: Ctx, cache: 60 }, async function trendingFn() {
return { items: [] }
})
const m = generateManifest()
const fn = m.contexts.trending.functions[0]
expect(fn.cache).toBe(60)
})
test('cache=60 still emits no-store on HTTP', async () => {
clearRegistry()
const Ctx = new ReactContext('live')
client({ context: Ctx, cache: 60 }, async function liveFn() {
return { score: 42 }
})
const r = await handleContextFetch('live', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
test('cache=false sets no-store', async () => {
clearRegistry()
const Ctx = new ReactContext('random')
client({ context: Ctx, cache: false }, async function randomFn() {
return { value: Math.random() }
})
const r = await handleContextFetch('random', {})
expect(r.headers['Cache-Control']).toBe('no-store')
})
})
// ── Cache Conformance Tests ────────────────────────────────────────────
describe('Cache Conformance', () => {
const SECRET = 'test-pin-secret-that-is-32bytes!'
test('deriveCacheKey determinism', () => {
const k1 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
const k2 = deriveCacheKey(SECRET, 'user', { user_id: '5' })
expect(k1).toBe(k2)
expect(k1).toStartWith('ctx:user:')
expect(k1).toHaveLength('ctx:user:'.length + 64)
})
test('deriveCacheKey param order irrelevant', () => {
const k1 = deriveCacheKey(SECRET, 'ctx', { a: '1', b: '2' })
const k2 = deriveCacheKey(SECRET, 'ctx', { b: '2', a: '1' })
expect(k1).toBe(k2)
})
test('deriveCacheKey cross-language pin (matches Python)', () => {
// These exact values are pinned from Python's derive_cache_key output.
// If this test fails, cross-language cache key compatibility is broken.
const publicKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, undefined, 0)
expect(publicKey).toBe('ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6')
const userScopedKey = deriveCacheKey(SECRET, 'user', { user_id: '5' }, '5', 0)
expect(userScopedKey).toBe('ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2')
})
test('MemoryCache get/set/clear', () => {
const cache = new MemoryCache()
expect(cache.get('k1')).toBeNull()
cache.set('k1', '{"data":true}')
expect(cache.get('k1')).toBe('{"data":true}')
cache.clear()
expect(cache.get('k1')).toBeNull()
})
test('scoped purge recomputes key directly', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user', { user_id: '5' }, SECRET)
expect(count).toBe(1)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).not.toBeNull()
})
test('broad purge removes all entries', () => {
const cache = new MemoryCache()
cachePut(SECRET, cache, 'user', { user_id: '5' }, '{"u5":true}')
cachePut(SECRET, cache, 'user', { user_id: '6' }, '{"u6":true}')
const count = cachePurge(cache, 'user')
expect(count).toBe(2)
expect(cacheGet(SECRET, cache, 'user', { user_id: '5' })).toBeNull()
expect(cacheGet(SECRET, cache, 'user', { user_id: '6' })).toBeNull()
})
test('handleContextFetch caches response', async () => {
clearRegistry()
const Ctx = new ReactContext('cached')
client({ context: Ctx }, async function cachedFn(itemId: number) {
return { value: itemId }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
const r1 = await handleContextFetch('cached', { itemId: '1' })
expect(r1.status).toBe(200)
expect(r1.headers['X-Mizan-Cache']).toBe('MISS')
const r2 = await handleContextFetch('cached', { itemId: '1' })
expect(r2.status).toBe(200)
expect(r2.headers['X-Mizan-Cache']).toBe('HIT')
expect(r2.body).toEqual(r1.body)
resetCache()
setCacheSecret(null)
})
test('handleMutationCall purges cache', async () => {
clearRegistry()
const Ctx = new ReactContext('product')
client({ context: Ctx }, async function getProduct(productId: number) {
return { id: productId }
})
client({ affects: Ctx }, async function updateProduct(productId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime cache
await handleContextFetch('product', { productId: '1' })
// Mutate
await handleMutationCall('updateProduct', { productId: 1, name: 'New' })
// Cache should be purged — next fetch is MISS
const r = await handleContextFetch('product', { productId: '1' })
expect(r.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
test('scoped invalidation preserves other entries', async () => {
clearRegistry()
const Ctx = new ReactContext('user')
client({ context: Ctx }, async function userProfile(userId: number) {
return { name: `user_${userId}` }
})
client({ affects: Ctx }, async function editUser(userId: number, name: string) {
return { ok: true }
})
const cache = new MemoryCache()
setCache(cache)
setCacheSecret(SECRET)
// Prime both users
await handleContextFetch('user', { userId: '5' })
await handleContextFetch('user', { userId: '6' })
// Mutate only user 5
await handleMutationCall('editUser', { userId: 5, name: 'New' })
// User 6 should still be cached
const r6 = await handleContextFetch('user', { userId: '6' })
expect(r6.headers['X-Mizan-Cache']).toBe('HIT')
// User 5 should be a miss
const r5 = await handleContextFetch('user', { userId: '5' })
expect(r5.headers['X-Mizan-Cache']).toBe('MISS')
resetCache()
setCacheSecret(null)
})
})

View File

@@ -0,0 +1,26 @@
[project]
name = "mizan-core"
version = "0.1.0"
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
requires-python = ">=3.10"
dependencies = [
"PyJWT>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mizan_core"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

View File

@@ -0,0 +1,115 @@
"""
Cache backends — MemoryCache (testing) and RedisCache (production).
Simple key-value stores. No reverse indexes. Cache keys are derived
from HMAC, so scoped purge just recomputes the key and deletes it.
Broad purge uses key-prefix scan (rare operation).
"""
from __future__ import annotations
from typing import Protocol
class CacheBackend(Protocol):
"""Interface that all Mizan cache backends implement."""
def get(self, key: str) -> bytes | None: ...
def set(self, key: str, value: bytes) -> None: ...
def delete(self, key: str) -> bool: ...
def delete_by_prefix(self, prefix: str) -> int: ...
def clear(self) -> None: ...
class MemoryCache:
"""
In-memory cache backend for testing.
Uses a Python dict. No persistence, no cross-process sharing.
"""
def __init__(self) -> None:
self._store: dict[str, bytes] = {}
def get(self, key: str) -> bytes | None:
return self._store.get(key)
def set(self, key: str, value: bytes) -> None:
self._store[key] = value
def delete(self, key: str) -> bool:
if key in self._store:
del self._store[key]
return True
return False
def delete_by_prefix(self, prefix: str) -> int:
to_delete = [k for k in self._store if k.startswith(prefix)]
for k in to_delete:
del self._store[k]
return len(to_delete)
def clear(self) -> None:
self._store.clear()
class RedisCache:
"""
Redis-backed cache backend for production.
Simple GET/SET/DEL. No reverse indexes. Scoped purge recomputes
the HMAC key and deletes directly. Broad purge uses SCAN.
"""
DEFAULT_TTL = 86400 # 24h safety-net
def __init__(
self,
redis_url: str,
prefix: str = "mizan:",
ttl: int | None = None,
) -> None:
try:
import redis as redis_lib
except ImportError:
raise ImportError(
"Redis is required for Mizan's cache backend. "
"Install it with: pip install mizan[cache]"
)
self._client = redis_lib.from_url(
redis_url,
socket_connect_timeout=5,
socket_timeout=5,
health_check_interval=30,
retry_on_timeout=True,
max_connections=50,
)
self._prefix = prefix
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
def _key(self, key: str) -> str:
return f"{self._prefix}{key}"
def get(self, key: str) -> bytes | None:
return self._client.get(self._key(key))
def set(self, key: str, value: bytes) -> None:
self._client.set(self._key(key), value, ex=self._ttl)
def delete(self, key: str) -> bool:
return self._client.unlink(self._key(key)) > 0
def delete_by_prefix(self, prefix: str) -> int:
pattern = f"{self._prefix}{prefix}*"
count = 0
cursor = 0
while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=1000)
if keys:
count += self._client.unlink(*keys)
if cursor == 0:
break
return count
def clear(self) -> None:
self.delete_by_prefix("")

View File

@@ -0,0 +1,59 @@
"""
Cache key derivation — HMAC-SHA256 over JSON-canonical form.
Protocol-critical: every Mizan adapter must produce identical output
for identical inputs. Cross-language conformance verified by pin tests.
Scoped purge recomputes the key directly — no reverse index needed.
Broad purge uses a context prefix scan.
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
# Context prefix for broad purge (SCAN pattern)
CONTEXT_KEY_PREFIX = "ctx:"
def derive_cache_key(
secret: str,
context: str,
params: dict[str, Any],
user_id: str | None = None,
rev: int = 0,
) -> str:
"""
Derive a deterministic HMAC-SHA256 cache key.
Returns a prefixed key: "ctx:{context}:{hmac_hex}" so that
broad purge can SCAN by prefix "ctx:{context}:*".
"""
def _normalize(v: Any) -> str:
"""Normalize values for cross-language HMAC consistency.
Python str(True)="True" but JS String(true)="true". Use JSON-native forms."""
if v is True:
return "true"
if v is False:
return "false"
if v is None:
return "null"
return str(v)
sorted_params = {k: _normalize(v) for k, v in sorted(params.items())}
key_data: dict[str, Any] = {"c": context, "p": sorted_params, "r": rev}
if user_id is not None:
key_data["u"] = str(user_id)
message = json.dumps(key_data, sort_keys=True, separators=(",", ":"))
hmac_hex = hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{CONTEXT_KEY_PREFIX}{context}:{hmac_hex}"

View File

@@ -35,10 +35,37 @@ from typing import (
get_type_hints,
)
from django.http import HttpRequest
from pydantic import BaseModel
# ─── Framework-response-base hook ───────────────────────────────────────────
#
# View-path detection — distinguishing functions that return data (RPC path)
# from functions that return a framework-native response object (view path) —
# requires knowing the framework's response base class. Each backend adapter
# registers its base class here at import time.
#
# Django sets this to django.http.HttpResponseBase. FastAPI would set it to
# starlette.responses.Response. If unset, all functions are treated as RPC.
_framework_response_base: type | None = None
def set_framework_response_base(cls: type) -> None:
"""Backends register their framework's response base class for view-path detection."""
global _framework_response_base
_framework_response_base = cls
def is_framework_response(obj_or_cls: Any) -> bool:
"""True if obj_or_cls is, or is a subclass of, the registered framework response base."""
if _framework_response_base is None:
return False
if isinstance(obj_or_cls, type):
return issubclass(obj_or_cls, _framework_response_base)
return isinstance(obj_or_cls, _framework_response_base)
# =============================================================================
# REACT CONTEXT - Named context marker
# =============================================================================
@@ -116,8 +143,8 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
Input: ClassVar[type[BaseModel]] = BaseModel
Output: ClassVar[type[BaseModel]] = BaseModel
def __init__(self, request: HttpRequest):
"""Initialize with the Django request."""
def __init__(self, request: Any):
"""Initialize with the framework's request object (HttpRequest in Django, Request in FastAPI, etc.)."""
self.request = request
@property
@@ -187,9 +214,8 @@ class _FunctionWrapper(ServerFunction):
else:
result = self._wrapped_fn(self.request)
# View path — return HttpResponse directly (no serialization)
from django.http import HttpResponseBase
if isinstance(result, HttpResponseBase):
# View path — return a framework-native response directly (no serialization)
if is_framework_response(result):
return result
# Wrap primitive returns in the generated output model
@@ -261,6 +287,8 @@ def client(
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | Callable[[Any], bool] | None = None,
rev: int = 0,
cache: int | bool = True,
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
"""
Register a function as a server function.
@@ -336,7 +364,7 @@ def client(
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
# Support both @client and @client(...)
@@ -344,7 +372,7 @@ def client(
return _create_server_function(
fn, context=resolved_context, affects=affects,
private=private, route=route, methods=methods,
websocket=websocket, auth=auth,
websocket=websocket, auth=auth, rev=rev, cache=cache,
)
return decorator
@@ -387,6 +415,8 @@ def _create_server_function(
methods: list[str] | None = None,
websocket: bool = False,
auth: bool | str | None = None,
rev: int = 0,
cache: int | bool = True,
) -> type[ServerFunction]:
"""Internal helper that creates a ServerFunction from a decorated function."""
from pydantic import create_model
@@ -427,12 +457,10 @@ def _create_server_function(
if output_type is None:
raise TypeError(f"Server function '{name}' must have a return type annotation")
# Detect view path: function returns HttpResponse (or has no return annotation
# that maps to a model — view functions often just have -> HttpResponse)
from django.http import HttpResponseBase
is_view_path = (
isinstance(output_type, type) and issubclass(output_type, HttpResponseBase)
)
# Detect view path: function returns a framework-native response type
# (e.g. Django HttpResponse, FastAPI Response). View functions often just
# have -> HttpResponse with no Pydantic model.
is_view_path = is_framework_response(output_type)
if is_view_path:
# View path — no Pydantic output wrapping needed
@@ -523,8 +551,17 @@ def _create_server_function(
else:
meta["auth"] = auth
if meta:
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
# Revision: bumped by developer when function logic changes.
# Part of the HMAC cache key — old entries become unreachable orphans.
if rev != 0:
meta["rev"] = rev
# Cache policy: True=forever (default), False=no-store, int=TTL seconds
if cache is not True:
meta["cache"] = cache
# Always assign a fresh dict to prevent shared-dict mutation across classes
FunctionWrapper._meta = {**meta}
# Note: Registration happens via discovery (mizan_clients), not here.
# This allows the decorator to be used without import-time side effects.
@@ -652,7 +689,7 @@ def compose(
"""
def decorator(fn: Callable) -> ComposedContext:
from mizan.setup.registry import register_compose
from mizan_core.registry import register_compose
name = fn.__name__
@@ -763,7 +800,7 @@ class FormSchemaOutput(BaseModel):
def create_form_functions(
form_class: type,
name: str,
submit_handler: Callable[[HttpRequest, dict], BaseModel] | None = None,
submit_handler: Callable[[Any, dict], BaseModel] | None = None,
) -> tuple[type[ServerFunction], type[ServerFunction], type[ServerFunction] | None]:
"""
Generate server functions for a Django Form.

View File

@@ -0,0 +1,169 @@
"""
MWT (Mizan Web Token) — Protocol-owned identity layer.
MWT is a standard JWT (RFC 7519, HMAC-SHA256) with Mizan-specific claims,
traveling on the `X-Mizan-Token` header. It provides:
- `sub`: user_id for HMAC cache key derivation
- `pkey`: permission state hash for staleness detection
- `kid`: key ID in the JOSE header (per RFC 7515) for secret rotation
- `aud`: audience binding to prevent cross-tenant replay
- `nbf`: not-before to handle clock skew
MWT is issued from an authenticated Django session. The app handles
authentication (session, social auth, etc.); Mizan issues MWT from
the authenticated identity. Edge Workers and the origin-side cache
validate MWT to extract user identity for cache operations.
Usage:
from mizan.mwt import create_mwt, decode_mwt, MWTUser
Configuration:
MIZAN_MWT_SECRET: MWT signing key (separate from MIZAN_CACHE_SECRET)
MIZAN_MWT_TTL: token lifetime in seconds (default: 300)
"""
from __future__ import annotations
import hashlib
import logging
import time
from dataclasses import dataclass
from typing import Any
import jwt
logger = logging.getLogger("mizan.mwt")
@dataclass
class MWTPayload:
"""Decoded MWT claims."""
sub: str # user_id
staff: bool # is_staff
super: bool # is_superuser
pkey: str # permission state hash (full SHA-256 hex)
kid: str # key ID (from JOSE header)
aud: str # audience
iat: int # issued at
exp: int # expiration
class MWTUser:
"""
Minimal user object created from MWT claims.
Used as request.user for MWT-authenticated requests.
No database query required — all data comes from the token.
"""
def __init__(self, payload: MWTPayload):
self.id = int(payload.sub)
self.pk = self.id
self.is_staff = payload.staff
self.is_superuser = payload.super
self.is_authenticated = True
self.is_anonymous = False
self.is_active = True
self.pkey = payload.pkey
def __str__(self) -> str:
return f"MWTUser(id={self.id})"
def __repr__(self) -> str:
return f"MWTUser(id={self.id}, pkey={self.pkey[:8]}...)"
def compute_permission_key(user: Any) -> str:
"""
Compute a deterministic hash of the user's permission state.
Includes is_staff, is_superuser, and all Django permissions.
When the MWT expires and is refreshed, the new pkey reflects
any permission changes. The short TTL controls the staleness window.
Returns the full 64-character SHA-256 hex digest.
"""
perms = sorted(user.get_all_permissions()) if hasattr(user, "get_all_permissions") else []
staff = "1" if getattr(user, "is_staff", False) else "0"
superuser = "1" if getattr(user, "is_superuser", False) else "0"
blob = f"{staff}:{superuser}:{','.join(perms)}"
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def create_mwt(
user: Any,
secret: str,
ttl: int = 300,
audience: str = "mizan",
kid: str = "v1",
) -> str:
"""
Create an MWT from an authenticated Django user.
Args:
user: Django user object (must have pk, is_staff, is_superuser).
secret: MIZAN_MWT_SECRET signing key.
ttl: Token lifetime in seconds (default: 300 = 5 minutes).
audience: Audience claim for cross-tenant protection.
kid: Key ID placed in JOSE header (per RFC 7515) for rotation.
Returns:
Encoded JWT string.
"""
now = int(time.time())
payload = {
"sub": str(user.pk),
"staff": getattr(user, "is_staff", False),
"super": getattr(user, "is_superuser", False),
"pkey": compute_permission_key(user),
"aud": audience,
"iat": now,
"nbf": now,
"exp": now + ttl,
}
# kid goes in the JOSE header per RFC 7515, not the payload
headers = {"kid": kid}
return jwt.encode(payload, secret, algorithm="HS256", headers=headers)
def decode_mwt(
token: str,
secret: str,
audience: str = "mizan",
) -> MWTPayload | None:
"""
Decode and validate an MWT.
Returns MWTPayload on success, None on any failure (expired, invalid
signature, wrong audience, not-yet-valid, malformed).
"""
try:
# Decode header first to extract kid
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid", "v1")
data = jwt.decode(
token,
secret,
algorithms=["HS256"],
audience=audience,
)
except jwt.PyJWTError:
logger.debug("MWT decode failed", exc_info=True)
return None
try:
return MWTPayload(
sub=data["sub"],
staff=data.get("staff", False),
super=data.get("super", False),
pkey=data.get("pkey", ""),
kid=kid,
aud=audience,
iat=data["iat"],
exp=data["exp"],
)
except (KeyError, TypeError):
logger.debug("MWT payload missing required claims", exc_info=True)
return None

View File

@@ -0,0 +1,230 @@
"""
Mizan core registry — function and composition registration with an
extension hook for backend-specific registries (channels, forms, etc.)
to plug into.
This is the framework-agnostic registry. Backends own their own
type-specific registries (channels in Django Channels, forms in Django
Forms, websockets in FastAPI, etc.) and register them as extensions
here so the unified schema export can include them.
"""
from __future__ import annotations
from typing import Any, Callable, Protocol
# ─── Core registries ────────────────────────────────────────────────────────
_functions: dict[str, Any] = {}
_compositions: dict[str, Any] = {}
# ─── Extension hook ─────────────────────────────────────────────────────────
class RegistryExtension(Protocol):
"""
Backend-specific registries plug into core via this Protocol.
Each extension owns its own registry of backend-shaped registrations
(channels, forms, websocket consumers, etc.) and contributes a schema
subdict to the unified schema export.
"""
def schema(self) -> dict[str, Any]: ...
def clear(self) -> None: ...
_extensions: dict[str, RegistryExtension] = {}
def register_extension(name: str, extension: RegistryExtension) -> None:
"""
Register a backend extension. The extension contributes to schema
output under its name (e.g. 'channels', 'forms').
"""
_extensions[name] = extension
# ─── Function registration ──────────────────────────────────────────────────
def register(view_class: Any, name: str) -> Any:
"""
Register a server function class. Used by the @client decorator.
Idempotent for the same class (supports module reloads).
"""
view_class.name = name
if name in _functions:
existing = _functions[name]
if existing.__name__ == view_class.__name__:
_functions[name] = view_class
return view_class
raise ValueError(
f"Function '{name}' already registered by {existing.__name__}"
)
_functions[name] = view_class
return view_class
def register_as(name: str) -> Callable[[Any], Any]:
"""Decorator form of register()."""
def decorator(view_class: Any) -> Any:
return register(view_class, name)
return decorator
def register_compose(composed: Any, name: str) -> Any:
"""Register a composed context."""
if name in _compositions:
existing = _compositions[name]
if existing.name == composed.name:
_compositions[name] = composed
return composed
raise ValueError(
f"Composition '{name}' already registered by {existing.name}"
)
_compositions[name] = composed
return composed
# ─── Lookups ────────────────────────────────────────────────────────────────
def get_function(name: str) -> Any | None:
"""Get a registered server function by name."""
return _functions.get(name)
def get_compose(name: str) -> Any | None:
"""Get a registered composition by name."""
return _compositions.get(name)
def get_all_functions() -> dict[str, Any]:
"""Get all registered functions."""
return _functions.copy()
def get_all_compositions() -> dict[str, Any]:
"""Get all registered compositions."""
return _compositions.copy()
def get_contexts() -> dict[str, Any]:
"""Get all server functions marked as contexts (meta.context truthy)."""
return {
name: cls
for name, cls in _functions.items()
if getattr(cls, "_meta", {}).get("context")
}
def get_context_groups() -> dict[str, list[str]]:
"""Group function names by their context string."""
groups: dict[str, list[str]] = {}
for name, cls in _functions.items():
ctx = getattr(cls, "_meta", {}).get("context")
if ctx:
groups.setdefault(ctx, []).append(name)
return groups
def get_registry() -> dict[str, Any]:
"""
Full registry organized by type, including extension contributions.
Returns:
{
"functions": {...},
"compositions": {...},
"<extension>": {...}, # one per registered extension
}
"""
out: dict[str, Any] = {
"functions": _functions.copy(),
"compositions": _compositions.copy(),
}
for name, ext in _extensions.items():
# Extensions optionally expose their backing dict via .all()
# (Protocol doesn't require it; only schema() and clear() are mandatory)
if hasattr(ext, "all"):
out[name] = ext.all()
return out
def get_schema() -> dict[str, Any]:
"""
Export the unified schema for codegen consumption.
Aggregates function and composition schemas plus contributions from
each registered backend extension under its name.
"""
schema: dict[str, Any] = {
"functions": {
name: cls.get_schema_export() for name, cls in _functions.items()
},
"compositions": {
name: {
"name": composed.name,
"type": "compose",
"meta": composed._meta,
"children": composed._meta.get("children", []),
"leaves": composed._meta.get("leaves", []),
}
for name, composed in _compositions.items()
},
}
for name, ext in _extensions.items():
schema[name] = ext.schema()
return schema
# ─── Validation ─────────────────────────────────────────────────────────────
def validate_registry() -> list[str]:
"""
Check that all `affects` targets resolve to known contexts or functions.
Emits warnings for unresolved targets (e.g. typos in string-based affects).
Returns the list of warning messages (empty if all resolve).
"""
import warnings
issues: list[str] = []
groups = get_context_groups()
all_fn_names = set(_functions.keys())
for fn_name, fn_cls in _functions.items():
meta = getattr(fn_cls, "_meta", {})
affects = meta.get("affects")
if not affects:
continue
for target in affects:
target_name = target.get("name", "")
target_type = target.get("type", "")
if target_type == "context" and target_name not in groups:
issues.append(
f"@client function '{fn_name}' declares affects='{target_name}', "
f"but no context named '{target_name}' is registered."
)
elif target_type == "function" and target_name not in all_fn_names:
issues.append(
f"@client function '{fn_name}' targets function '{target_name}', "
f"but no function named '{target_name}' is registered."
)
for msg in issues:
warnings.warn(msg, stacklevel=2)
return issues
# ─── Clear (testing) ────────────────────────────────────────────────────────
def clear_registry() -> None:
"""Clear all registrations, including extension state. For testing."""
_functions.clear()
_compositions.clear()
for ext in _extensions.values():
ext.clear()

View File

View File

@@ -0,0 +1,68 @@
"""Unit tests for cache key derivation. Includes the cross-language pin against mizan-ts."""
from unittest import TestCase
from mizan_core.cache.keys import derive_cache_key
class CacheKeyDerivationTests(TestCase):
"""Tests that HMAC cache key derivation is deterministic and correct."""
SECRET = "test-cache-secret"
def test_deterministic_output(self):
"""Same inputs always produce the same key."""
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
self.assertEqual(key1, key2)
self.assertTrue(key1.startswith("ctx:user:"))
self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex
def test_param_order_irrelevant(self):
"""Parameter ordering does not affect the key."""
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
self.assertEqual(key1, key2)
def test_different_user_ids_different_keys(self):
"""Different user_ids produce different cache keys."""
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
self.assertNotEqual(key1, key2)
def test_rev_changes_key(self):
"""Different rev values produce different cache keys."""
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
self.assertNotEqual(key1, key2)
def test_no_delimiter_collision(self):
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
self.assertNotEqual(key1, key2)
def test_public_vs_user_scoped(self):
"""Public (no user_id) and user-scoped produce different keys."""
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
self.assertNotEqual(public, scoped)
def test_cross_language_pin(self):
"""Pinned HMAC values — must match TypeScript adapter exactly."""
pin_secret = "test-pin-secret-that-is-32bytes!"
public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0)
self.assertEqual(
public_key,
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6",
)
user_scoped_key = derive_cache_key(
pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0,
)
self.assertEqual(
user_scoped_key,
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2",
)

View File

@@ -0,0 +1,97 @@
"""Unit tests for MWT creation, decoding, and permission key derivation."""
from unittest import TestCase
from unittest.mock import MagicMock
from mizan_core.mwt import (
MWTUser,
compute_permission_key,
create_mwt,
decode_mwt,
)
def _make_user(**kwargs):
user = MagicMock()
user.pk = kwargs.get("pk", 1)
user.is_staff = kwargs.get("is_staff", False)
user.is_superuser = kwargs.get("is_superuser", False)
user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set()))
return user
class MWTCreationTests(TestCase):
"""Tests for MWT creation and decoding."""
SECRET = "test-mwt-secret-that-is-32bytes!"
def test_create_and_decode(self):
"""Create an MWT and decode it successfully."""
user = _make_user(pk=42, is_staff=True)
token = create_mwt(user, self.SECRET, ttl=300)
payload = decode_mwt(token, self.SECRET)
self.assertIsNotNone(payload)
self.assertEqual(payload.sub, "42")
self.assertTrue(payload.staff)
self.assertFalse(payload.super)
self.assertEqual(payload.kid, "v1")
self.assertEqual(len(payload.pkey), 64)
def test_decode_expired(self):
"""Expired MWT returns None."""
user = _make_user()
token = create_mwt(user, self.SECRET, ttl=-1)
payload = decode_mwt(token, self.SECRET)
self.assertIsNone(payload)
def test_decode_wrong_secret(self):
"""MWT signed with wrong secret returns None."""
user = _make_user()
token = create_mwt(user, self.SECRET)
payload = decode_mwt(token, "wrong-secret")
self.assertIsNone(payload)
def test_decode_wrong_audience(self):
"""MWT with wrong audience returns None."""
user = _make_user()
token = create_mwt(user, self.SECRET, audience="app1")
payload = decode_mwt(token, self.SECRET, audience="app2")
self.assertIsNone(payload)
def test_mwt_user_has_pkey(self):
"""MWTUser carries the permission key."""
user = _make_user(pk=5, perms={"app.view_thing"})
token = create_mwt(user, self.SECRET)
payload = decode_mwt(token, self.SECRET)
mwt_user = MWTUser(payload)
self.assertEqual(mwt_user.pk, 5)
self.assertTrue(mwt_user.is_authenticated)
self.assertEqual(len(mwt_user.pkey), 64)
class PermissionKeyTests(TestCase):
"""Tests for pkey determinism and sensitivity."""
def test_deterministic(self):
"""Same permissions produce same pkey."""
user = _make_user(perms={"app.view_thing", "app.add_thing"})
pkey1 = compute_permission_key(user)
pkey2 = compute_permission_key(user)
self.assertEqual(pkey1, pkey2)
def test_changes_on_permission_change(self):
"""Different permissions produce different pkey."""
user1 = _make_user(perms={"app.view_thing"})
user2 = _make_user(perms={"app.view_thing", "app.add_thing"})
self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2))
def test_changes_on_staff_change(self):
"""Staff status change produces different pkey."""
user_normal = _make_user(is_staff=False)
user_staff = _make_user(is_staff=True)
self.assertNotEqual(
compute_permission_key(user_normal),
compute_permission_key(user_staff),
)

92
docs/AFI_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,92 @@
# AFI Architecture
Mizan is an **Application Framework Interface (AFI)** — the
server-client unification layer.
## Package layout
Tree organized by role.
```
backends/ server protocol adapters
mizan-django/ Django adapter
mizan-fastapi/ FastAPI adapter (AFI-common scope)
mizan-ts/ TypeScript adapter (proves the protocol is language-agnostic)
frontends/ client kernel + per-framework adapters
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
mizan-svelte/ Svelte stores/runes over the kernel
cores/ shared language-level primitives
mizan-python/ @client decorator, registry, MWT, HMAC cache keys
protocol/ protocol-level tooling
mizan-generate/ codegen — schema in, typed client out
workers/ runtime workers / bridges
mizan-ssr/ Bun subprocess used by the Django template backend
```
## Two orthogonal products
- **RPC** — typed client generation via codegen
- **SSR** — server rendering via the Bun bridge
Independent and composable. Either ships standalone; together they
compose.
## Kernel model
The client kernel (`mizan-base`) is the one hard thing. Per-
framework adapters are thin idiomatic wrappers around it. Codegen
emits typed bindings against the framework adapter's surface, not
against the raw kernel — so a React developer gets `useEcho()` and
`<MizanContext>`, a Vue developer gets `useEcho()` composables, a
Svelte developer gets readable stores. Same kernel underneath.
## KDL is the IR
The Mizan IR is **KDL** — the LLVM-IR-equivalent of the system. Every
backend adapter produces KDL describing its registered functions,
contexts, types, and invalidation graph. Every codegen target consumes
KDL. KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it.
The IR must be validated against multiple adapters before it is
considered stable. Single-adapter validation hides assumptions —
divergence between adapters is what the IR exists to prevent.
Forward-direction primitives:
- `cores/mizan-python` builds the IR from registered functions
(`build_ir()` walks `mizan_core.registry`, emits KDL)
- A `mizan-schema` package (forthcoming) holds the canonical KDL
grammar / type system definition that every adapter targets
- Codegen reads KDL directly — no OpenAPI envelope, no
`openapi-typescript`, no per-backend converter divergence
- Edge manifest, MWT claims, and other protocol artifacts all derive
from the same KDL
**Current implementation is transitional.** Today the codegen consumes
OpenAPI 3.0 (`x-mizan-functions` + `x-mizan-contexts` extensions over
Pydantic→JSON-Schema), produced via Django Ninja or FastAPI's native
generator. That layered indirection is what introduces adapter
divergence (see the AFI conformance suite). KDL-as-IR collapses it.
## Launch surface
Python (Django) + React. Vue and Svelte ship as v1 alongside React.
TypeScript backend (`mizan-ts`) proves the protocol is portable.
## Why the AFI shape
Quadratic ecosystem growth (N server adapters × M client adapters)
collapses to linear (one adapter per stack) when both sides
communicate through a shared protocol.
## Invariants
- All cross-package communication goes through the protocol. No
direct cross-package dependencies.
- New adapters land as new packages, not as modifications to existing
ones.
- Framework adapters wrap the kernel in framework idioms — they
don't bypass it. Codegen targets the adapter, not the raw kernel.

73
docs/CACHE_KEYING.md Normal file
View File

@@ -0,0 +1,73 @@
# Cache Keying
*Discovered 2026-04-06.*
## The gap
Mizan specified invalidation but never specified cache keying.
Without correct cache keying, Edge caching is a **security
vulnerability** — it serves User A's content to User B.
## Why Vary doesn't work
All major CDNs ignore `Vary` for personalized content. No
standardized replacement exists.
## Resolution: HMAC cache key (JSON-canonical form)
```
HMAC-SHA256(secret, JSON.stringify({
"c": context,
"p": sorted_params,
"r": rev,
"u": user_id // omitted for public content
}, sort_keys=True))
```
### Key derivation rules
- **Public content** — URL path + query params (standard CDN).
- **User-scoped content** — HMAC key derivation above.
- **`@client(auth=...)`** determines whether content is user-scoped.
- **`rev` parameter** on `@client` for deploy-time logic
invalidation. Bumped by the developer when function logic changes.
## Identity layer
MWT (Mizan Web Token) — see [MWT_SPEC.md](MWT_SPEC.md). JWT with
Mizan claims on `X-Mizan-Token` header. Replaces the old
`JWTUser` + permission key metadata approach.
## Cache architecture
*Decided 2026-04-06.*
**Not a compiled binary ABI. Not a pluggable Python protocol.**
Each backend adapter (Python, TypeScript, future PHP/C#/Go)
implements the cache protocol in its own language, backed by Redis.
**Conformance verified by a shared test suite.**
### Required operations
- `cache_get`
- `cache_put`
- `cache_purge`
- `cache_purge_user`
### Storage
Redis only. Handles persistence, cross-worker sharing, crash
recovery.
## Deploy invalidation
No full context flush. The `rev` parameter on `@client` is part of
the HMAC key. When the developer bumps `rev`, old cache entries
become **unreachable orphans**. No purge needed; no thundering herd.
## Invariant
All cache-related code must implement *identical* HMAC key
derivation. Cross-language conformance tests enforce this. Any
divergence is a security vulnerability.

Some files were not shown because too many files have changed in this diff Show More