voodoo-framework 2.6.0__tar.gz → 2.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voodoo_framework-2.6.0/src/voodoo_framework.egg-info → voodoo_framework-2.6.1}/PKG-INFO +1 -1
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/__init__.py +1 -1
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/config.py +5 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/__init__.py +10 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/auth.py +7 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/errors.py +45 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/gateway.py +152 -13
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/http.py +30 -5
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/mqtt.py +89 -5
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/simulator.py +65 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/store.py +212 -8
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1/src/voodoo_framework.egg-info}/PKG-INFO +1 -1
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/SOURCES.txt +2 -0
- voodoo_framework-2.6.1/tests/test_edge_e2e.py +186 -0
- voodoo_framework-2.6.1/tests/test_edge_gateway.py +1002 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_http.py +38 -0
- voodoo_framework-2.6.1/tests/test_edge_security.py +399 -0
- voodoo_framework-2.6.0/tests/test_edge_gateway.py +0 -417
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/LICENSE +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/README.md +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/pyproject.toml +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/setup.cfg +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/capabilities.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/registry.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/tailwind.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/voodoo_css.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agent.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/models.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/registry.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/agent.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/anthropic.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/gemini.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/mock.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/ollama.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/openai.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/tools/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/tools/registry.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/api.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/cookies.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/guards.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/jwt.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/middleware.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/passwords.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/user.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/__main__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/agents.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/ai.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/approvals.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/auth.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/create.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/dev.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/doctor.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/executions.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/generate.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/inspect.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/new.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/objects.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/protocol.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/recover.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/routes.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/scaffolding.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/schedules.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/status.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/tasks.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/terminal.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/theme.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/version.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/workers_cmd.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/components.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/app.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/errors.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/events.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/render.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/routing.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/sitemap.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/state.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/base.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/model.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/models.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/protocol.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/i18n.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mcp/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mcp/client.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/client.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/event_bus.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/capability.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/compute.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/constraint.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/effect.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/intent.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/resource.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/state.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/time.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/export.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/schemas.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/py.typed +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/queue.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/routing/api.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/routing/pages.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/adaptive.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/capability.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/constraint.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/context.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/engine.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/errors.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/execution.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/graph.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/human.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/persistence.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/planner.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/scheduler.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/task.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/workflow.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/schedule.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/cors.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/csrf.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/headers.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/passwords.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/rate_limit.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/redaction.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/secrets.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/seo.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/static/client.js +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/status.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/memory.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/redis.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/postgres.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/local.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/postgres.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/migrations.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/postgres.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/manager.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/local.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/s3.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/interfaces.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/memory.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/postgres.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/redis.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/scheduler/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/scheduler/sqlite.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/api.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/middleware.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/otlp.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/store.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/theme.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/tools/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/tools/registry.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/component.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/events.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/library.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/markdown.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/rendering.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/state.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/presets.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/theme.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/workers/__init__.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/workers/queue.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/dependency_links.txt +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/entry_points.txt +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/requires.txt +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/top_level.txt +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent_registry.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent_runtime.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_app.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_auth.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_capability_security.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_checkpoint_resume.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_chrome.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_cli.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_cli_inspect.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_components.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_config.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_contract_api.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_create.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_data.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_design_system.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_durable_hitl.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_device.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_protocol.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_events.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_execution_sqlite_store.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_http_runtime.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_human.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_i18n.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_integration.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mcp.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mcp_runtime.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_memory.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mesh.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mesh_event_bus.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_model.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_object_store.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_observability.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_persistence.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_planner_adaptive.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_primitives.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_protocol.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_provider_switching.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_providers.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_queue.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_reactive.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_runtime.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_scheduler.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_security.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_seo.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_state.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_theme.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_theme_presets.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_tools.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_ui.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_websocket.py +0 -0
- {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_workers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voodoo-framework
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: The programmable runtime for adaptive applications and operational systems — web, APIs, agents, workers, human workflows, distributed systems, and physical systems in one runtime.
|
|
5
5
|
Author-email: Helder Perez <contact@helderperez.com>
|
|
6
6
|
Maintainer-email: Helder Perez <contact@helderperez.com>
|
|
@@ -284,6 +284,11 @@ class EdgeConfig(BaseModel):
|
|
|
284
284
|
max_state_size: int = 65536 # 64 KiB
|
|
285
285
|
max_pending_effects: int = 1000
|
|
286
286
|
heartbeat_interval: int = 30
|
|
287
|
+
# Enrollment security (Sprint 23.1).
|
|
288
|
+
enrollment_auth_required: bool = True
|
|
289
|
+
enrollment_admin_token: str = ""
|
|
290
|
+
# Effect delivery (Sprint 23.1).
|
|
291
|
+
max_effect_retries: int = 3
|
|
287
292
|
|
|
288
293
|
|
|
289
294
|
class VoodooConfig(BaseModel):
|
|
@@ -27,6 +27,8 @@ from voodoo.edge.auth import (
|
|
|
27
27
|
from voodoo.edge.errors import (
|
|
28
28
|
AuthenticationFailedError,
|
|
29
29
|
AuthorizationFailedError,
|
|
30
|
+
DeliveryFailedError,
|
|
31
|
+
DeviceIdMismatchError,
|
|
30
32
|
DeviceNotFoundError,
|
|
31
33
|
DeviceRevokedError,
|
|
32
34
|
DuplicateMessageError,
|
|
@@ -37,6 +39,9 @@ from voodoo.edge.errors import (
|
|
|
37
39
|
InvalidMessageError,
|
|
38
40
|
InvalidProtocolVersionError,
|
|
39
41
|
InvalidStateVersionError,
|
|
42
|
+
PayloadTooLargeError,
|
|
43
|
+
SessionInvalidError,
|
|
44
|
+
SessionRequiredError,
|
|
40
45
|
TransportError,
|
|
41
46
|
error_response,
|
|
42
47
|
)
|
|
@@ -109,7 +114,9 @@ __all__ = [
|
|
|
109
114
|
"error_response",
|
|
110
115
|
"AuthenticationFailedError",
|
|
111
116
|
"AuthorizationFailedError",
|
|
117
|
+
"DeliveryFailedError",
|
|
112
118
|
"DeviceNotFoundError",
|
|
119
|
+
"DeviceIdMismatchError",
|
|
113
120
|
"DeviceRevokedError",
|
|
114
121
|
"DuplicateMessageError",
|
|
115
122
|
"EffectExpiredError",
|
|
@@ -118,5 +125,8 @@ __all__ = [
|
|
|
118
125
|
"InvalidMessageError",
|
|
119
126
|
"InvalidProtocolVersionError",
|
|
120
127
|
"InvalidStateVersionError",
|
|
128
|
+
"PayloadTooLargeError",
|
|
129
|
+
"SessionInvalidError",
|
|
130
|
+
"SessionRequiredError",
|
|
121
131
|
"TransportError",
|
|
122
132
|
]
|
|
@@ -223,6 +223,13 @@ async def authenticate_device(
|
|
|
223
223
|
|
|
224
224
|
await store.mark_credential_used(record.credential_id)
|
|
225
225
|
|
|
226
|
+
# Reconnect: invalidate any existing sessions for this device before
|
|
227
|
+
# creating a new one. The device transitions RECONNECTING → CONNECTED
|
|
228
|
+
# so downstream observers see a clean lifecycle signal (EDGE §30).
|
|
229
|
+
old_session_count = await store.delete_device_sessions(device.device_id)
|
|
230
|
+
if old_session_count > 0:
|
|
231
|
+
await store.update_device_status(device.device_id, DeviceStatus.RECONNECTING)
|
|
232
|
+
|
|
226
233
|
session = DeviceSession(
|
|
227
234
|
device_id=device.device_id,
|
|
228
235
|
transport=transport,
|
|
@@ -17,6 +17,7 @@ __all__ = [
|
|
|
17
17
|
"AuthenticationFailedError",
|
|
18
18
|
"AuthorizationFailedError",
|
|
19
19
|
"DeviceNotFoundError",
|
|
20
|
+
"DeviceIdMismatchError",
|
|
20
21
|
"DeviceRevokedError",
|
|
21
22
|
"InvalidMessageError",
|
|
22
23
|
"InvalidProtocolVersionError",
|
|
@@ -25,6 +26,9 @@ __all__ = [
|
|
|
25
26
|
"DuplicateMessageError",
|
|
26
27
|
"EffectNotFoundError",
|
|
27
28
|
"EffectExpiredError",
|
|
29
|
+
"SessionRequiredError",
|
|
30
|
+
"SessionInvalidError",
|
|
31
|
+
"DeliveryFailedError",
|
|
28
32
|
"TransportError",
|
|
29
33
|
"HTTP_STATUS",
|
|
30
34
|
"error_response",
|
|
@@ -50,6 +54,9 @@ class EdgeErrorCode:
|
|
|
50
54
|
DUPLICATE_MESSAGE = "DUPLICATE_MESSAGE"
|
|
51
55
|
EFFECT_NOT_FOUND = "EFFECT_NOT_FOUND"
|
|
52
56
|
EFFECT_EXPIRED = "EFFECT_EXPIRED"
|
|
57
|
+
SESSION_REQUIRED = "SESSION_REQUIRED"
|
|
58
|
+
SESSION_INVALID = "SESSION_INVALID"
|
|
59
|
+
DELIVERY_FAILED = "DELIVERY_FAILED"
|
|
53
60
|
TRANSPORT_ERROR = "TRANSPORT_ERROR"
|
|
54
61
|
|
|
55
62
|
|
|
@@ -66,6 +73,9 @@ HTTP_STATUS: dict[str, int] = {
|
|
|
66
73
|
EdgeErrorCode.DUPLICATE_MESSAGE: 200, # idempotent success, not an error for the client
|
|
67
74
|
EdgeErrorCode.EFFECT_NOT_FOUND: 404,
|
|
68
75
|
EdgeErrorCode.EFFECT_EXPIRED: 410,
|
|
76
|
+
EdgeErrorCode.SESSION_REQUIRED: 401,
|
|
77
|
+
EdgeErrorCode.SESSION_INVALID: 401,
|
|
78
|
+
EdgeErrorCode.DELIVERY_FAILED: 502,
|
|
69
79
|
EdgeErrorCode.TRANSPORT_ERROR: 502,
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -163,6 +173,41 @@ class TransportError(EdgeError):
|
|
|
163
173
|
status = 502
|
|
164
174
|
|
|
165
175
|
|
|
176
|
+
class SessionRequiredError(AuthenticationFailedError):
|
|
177
|
+
"""Request requires an authenticated session."""
|
|
178
|
+
|
|
179
|
+
code = EdgeErrorCode.SESSION_REQUIRED
|
|
180
|
+
status = 401
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SessionInvalidError(AuthenticationFailedError):
|
|
184
|
+
"""Session ID is invalid or expired."""
|
|
185
|
+
|
|
186
|
+
code = EdgeErrorCode.SESSION_INVALID
|
|
187
|
+
status = 401
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class DeliveryFailedError(TransportError):
|
|
191
|
+
"""Effect delivery failed after max retries."""
|
|
192
|
+
|
|
193
|
+
code = EdgeErrorCode.DELIVERY_FAILED
|
|
194
|
+
status = 502
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class DeviceIdMismatchError(AuthorizationFailedError):
|
|
198
|
+
"""Message device_id does not match the authenticated device."""
|
|
199
|
+
|
|
200
|
+
code = EdgeErrorCode.AUTHORIZATION_FAILED
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PayloadTooLargeError(InvalidMessageError):
|
|
204
|
+
"""Message or state payload exceeds configured size limit."""
|
|
205
|
+
|
|
206
|
+
code = EdgeErrorCode.INVALID_MESSAGE
|
|
207
|
+
status = 413
|
|
208
|
+
status = 403
|
|
209
|
+
|
|
210
|
+
|
|
166
211
|
# ---------------------------------------------------------------------------
|
|
167
212
|
# Serialization
|
|
168
213
|
# ---------------------------------------------------------------------------
|
|
@@ -22,10 +22,12 @@ from voodoo.edge.auth import authenticate_device, consume_enrollment
|
|
|
22
22
|
from voodoo.edge.errors import (
|
|
23
23
|
AuthenticationFailedError,
|
|
24
24
|
AuthorizationFailedError,
|
|
25
|
+
DeviceIdMismatchError,
|
|
25
26
|
DeviceNotFoundError,
|
|
26
27
|
EffectNotFoundError,
|
|
27
28
|
InvalidMessageError,
|
|
28
29
|
InvalidStateVersionError,
|
|
30
|
+
PayloadTooLargeError,
|
|
29
31
|
)
|
|
30
32
|
from voodoo.edge.models import (
|
|
31
33
|
AuthenticatedDeviceContext,
|
|
@@ -50,6 +52,17 @@ logger = logging.getLogger("voodoo.edge")
|
|
|
50
52
|
__all__ = ["DeviceGateway"]
|
|
51
53
|
|
|
52
54
|
|
|
55
|
+
def _redact(value: str, *, show: int = 4) -> str:
|
|
56
|
+
"""Redact a sensitive value, showing only the last *show* characters.
|
|
57
|
+
|
|
58
|
+
Used for debug logging of device IDs and session tokens — credentials
|
|
59
|
+
are never logged (EDGE §9).
|
|
60
|
+
"""
|
|
61
|
+
if len(value) <= show:
|
|
62
|
+
return "***"
|
|
63
|
+
return f"{'*' * (len(value) - show)}{value[-show:]}"
|
|
64
|
+
|
|
65
|
+
|
|
53
66
|
# Events that are pure telemetry — never become Executions (EDGE §21).
|
|
54
67
|
_TELEMETRY_EVENTS = frozenset({"heartbeat", "device.heartbeat"})
|
|
55
68
|
|
|
@@ -68,10 +81,15 @@ class DeviceGateway:
|
|
|
68
81
|
"""
|
|
69
82
|
|
|
70
83
|
def __init__(
|
|
71
|
-
self,
|
|
84
|
+
self,
|
|
85
|
+
store: DeviceStoreProtocol,
|
|
86
|
+
engine: ExecutionEngine | None = None,
|
|
87
|
+
*,
|
|
88
|
+
config: Any | None = None,
|
|
72
89
|
) -> None:
|
|
73
90
|
self._store = store
|
|
74
91
|
self._engine = engine
|
|
92
|
+
self._config = config
|
|
75
93
|
# Authenticated contexts by session_id — reconnects re-attach.
|
|
76
94
|
self._contexts: dict[str, AuthenticatedDeviceContext] = {}
|
|
77
95
|
|
|
@@ -117,7 +135,8 @@ class DeviceGateway:
|
|
|
117
135
|
This is the canonical way transports authenticate — direct calls
|
|
118
136
|
to ``authenticate_device`` bypass session tracking. Returns
|
|
119
137
|
``(context, session)`` with the context registered for subsequent
|
|
120
|
-
messages.
|
|
138
|
+
messages. On reconnect, stale contexts for the same device are
|
|
139
|
+
evicted first.
|
|
121
140
|
"""
|
|
122
141
|
|
|
123
142
|
ctx, session = await authenticate_device(
|
|
@@ -126,6 +145,12 @@ class DeviceGateway:
|
|
|
126
145
|
claimed_device_id=claimed_device_id,
|
|
127
146
|
transport=transport,
|
|
128
147
|
)
|
|
148
|
+
# Evict any stale contexts for this device (reconnect cleanup).
|
|
149
|
+
stale_sids = [
|
|
150
|
+
sid for sid, c in self._contexts.items() if c.device_id == ctx.device_id
|
|
151
|
+
]
|
|
152
|
+
for sid in stale_sids:
|
|
153
|
+
self._contexts.pop(sid, None)
|
|
129
154
|
self._contexts[session.session_id] = ctx
|
|
130
155
|
await self._emit(
|
|
131
156
|
"device.connected",
|
|
@@ -138,6 +163,31 @@ class DeviceGateway:
|
|
|
138
163
|
)
|
|
139
164
|
return ctx, session
|
|
140
165
|
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Stateless authentication (HTTP — no session created)
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async def authenticate_request(
|
|
171
|
+
self,
|
|
172
|
+
credential: str,
|
|
173
|
+
*,
|
|
174
|
+
transport: TransportKind = TransportKind.HTTP,
|
|
175
|
+
) -> AuthenticatedDeviceContext:
|
|
176
|
+
"""Authenticate a device credential without creating a session.
|
|
177
|
+
|
|
178
|
+
For stateless transports (HTTP) where each request carries its
|
|
179
|
+
own credential. Validates the credential, checks the device is
|
|
180
|
+
not revoked, and updates ``last_seen_at``. Returns the
|
|
181
|
+
authenticated context only — no session is persisted.
|
|
182
|
+
"""
|
|
183
|
+
ctx, _session = await authenticate_device(
|
|
184
|
+
self._store,
|
|
185
|
+
credential,
|
|
186
|
+
transport=transport,
|
|
187
|
+
)
|
|
188
|
+
await self._store.update_last_seen(ctx.device_id)
|
|
189
|
+
return ctx
|
|
190
|
+
|
|
141
191
|
# ------------------------------------------------------------------
|
|
142
192
|
# Message entry point (EDGE §72 — thin route → gateway)
|
|
143
193
|
# ------------------------------------------------------------------
|
|
@@ -158,11 +208,34 @@ class DeviceGateway:
|
|
|
158
208
|
"""
|
|
159
209
|
message = decode_message(raw)
|
|
160
210
|
|
|
211
|
+
# Resource limit: message size (EDGE §76).
|
|
212
|
+
if self._config is not None:
|
|
213
|
+
edge_cfg = getattr(self._config, "edge", None)
|
|
214
|
+
if edge_cfg is not None:
|
|
215
|
+
max_size = getattr(edge_cfg, "max_message_size", 0)
|
|
216
|
+
if max_size > 0:
|
|
217
|
+
raw_size = (
|
|
218
|
+
len(raw) if isinstance(raw, (str, bytes)) else len(str(raw))
|
|
219
|
+
)
|
|
220
|
+
if raw_size > max_size:
|
|
221
|
+
raise PayloadTooLargeError(
|
|
222
|
+
f"Message size {raw_size} exceeds limit {max_size}"
|
|
223
|
+
)
|
|
224
|
+
|
|
161
225
|
# Protocol-version gate happens inside decode; re-check payload types.
|
|
162
226
|
if message.type is EdgeMessageType.AUTH:
|
|
163
227
|
return await self.handle_auth(message, transport=transport)
|
|
164
228
|
|
|
165
229
|
ctx = context or await self._require_context(message)
|
|
230
|
+
|
|
231
|
+
# Identity validation: message device_id must match the
|
|
232
|
+
# authenticated context (EDGE §77).
|
|
233
|
+
if message.device_id and message.device_id != ctx.device_id:
|
|
234
|
+
raise DeviceIdMismatchError(
|
|
235
|
+
f"Message device_id '{message.device_id}' does not match "
|
|
236
|
+
f"authenticated device '{ctx.device_id}'"
|
|
237
|
+
)
|
|
238
|
+
|
|
166
239
|
handler = {
|
|
167
240
|
EdgeMessageType.HELLO: self.handle_hello,
|
|
168
241
|
EdgeMessageType.STATE_SYNC: self.handle_state_sync,
|
|
@@ -236,13 +309,18 @@ class DeviceGateway:
|
|
|
236
309
|
"""Ingest a device event (EDGE §19–§21).
|
|
237
310
|
|
|
238
311
|
Duplicate message_ids (protocol retries) are idempotent — the
|
|
239
|
-
first delivery wins and retries receive the same response
|
|
240
|
-
Telemetry events update state
|
|
241
|
-
semantic events become Intents
|
|
312
|
+
first delivery wins and retries receive the **same** response
|
|
313
|
+
(replayed from the message log). Telemetry events update state
|
|
314
|
+
without creating Executions; semantic events become Intents
|
|
315
|
+
executed by the engine.
|
|
242
316
|
"""
|
|
243
317
|
payload = message.typed_payload() # EventPayload
|
|
244
318
|
if await self._store.seen_message(message.message_id):
|
|
245
|
-
# Idempotent duplicate:
|
|
319
|
+
# Idempotent duplicate: replay the original response.
|
|
320
|
+
stored = await self._store.get_stored_response(message.message_id)
|
|
321
|
+
if stored is not None:
|
|
322
|
+
return EdgeMessage.model_validate_json(stored)
|
|
323
|
+
# Fallback for messages logged before response storage existed.
|
|
246
324
|
return make_message(
|
|
247
325
|
EdgeMessageType.EVENT,
|
|
248
326
|
device_id=ctx.device_id,
|
|
@@ -269,7 +347,7 @@ class DeviceGateway:
|
|
|
269
347
|
|
|
270
348
|
if event_name in _TELEMETRY_EVENTS:
|
|
271
349
|
await self._store.update_last_seen(ctx.device_id)
|
|
272
|
-
|
|
350
|
+
response = make_message(
|
|
273
351
|
EdgeMessageType.EVENT,
|
|
274
352
|
device_id=ctx.device_id,
|
|
275
353
|
payload={
|
|
@@ -280,6 +358,10 @@ class DeviceGateway:
|
|
|
280
358
|
correlation_id=message.message_id,
|
|
281
359
|
trace_id=message.trace_id,
|
|
282
360
|
)
|
|
361
|
+
await self._store.store_response(
|
|
362
|
+
message.message_id, response.model_dump_json()
|
|
363
|
+
)
|
|
364
|
+
return response
|
|
283
365
|
|
|
284
366
|
execution_id: str | None = None
|
|
285
367
|
if self._engine is not None:
|
|
@@ -302,7 +384,7 @@ class DeviceGateway:
|
|
|
302
384
|
)
|
|
303
385
|
execution_id = execution.id
|
|
304
386
|
|
|
305
|
-
|
|
387
|
+
response = make_message(
|
|
306
388
|
EdgeMessageType.EVENT,
|
|
307
389
|
device_id=ctx.device_id,
|
|
308
390
|
payload={
|
|
@@ -313,6 +395,8 @@ class DeviceGateway:
|
|
|
313
395
|
correlation_id=message.message_id,
|
|
314
396
|
trace_id=message.trace_id,
|
|
315
397
|
)
|
|
398
|
+
await self._store.store_response(message.message_id, response.model_dump_json())
|
|
399
|
+
return response
|
|
316
400
|
|
|
317
401
|
async def handle_state_sync(
|
|
318
402
|
self, message: EdgeMessage, ctx: AuthenticatedDeviceContext
|
|
@@ -321,6 +405,20 @@ class DeviceGateway:
|
|
|
321
405
|
payload = message.typed_payload() # StateSyncPayload
|
|
322
406
|
device = await self._require_device(ctx.device_id)
|
|
323
407
|
|
|
408
|
+
# Resource limit: state size (EDGE §76).
|
|
409
|
+
if self._config is not None:
|
|
410
|
+
edge_cfg = getattr(self._config, "edge", None)
|
|
411
|
+
if edge_cfg is not None:
|
|
412
|
+
max_size = getattr(edge_cfg, "max_state_size", 0)
|
|
413
|
+
if max_size > 0:
|
|
414
|
+
import json
|
|
415
|
+
|
|
416
|
+
state_size = len(json.dumps(payload.state).encode())
|
|
417
|
+
if state_size > max_size:
|
|
418
|
+
raise PayloadTooLargeError(
|
|
419
|
+
f"State size {state_size} exceeds limit {max_size}"
|
|
420
|
+
)
|
|
421
|
+
|
|
324
422
|
applied = await self._store.update_device_state(
|
|
325
423
|
ctx.device_id, payload.state, payload.state_version
|
|
326
424
|
)
|
|
@@ -415,7 +513,8 @@ class DeviceGateway:
|
|
|
415
513
|
|
|
416
514
|
Enforces capability authorization at the runtime boundary
|
|
417
515
|
(EDGE §50) — a device without the required capability never
|
|
418
|
-
receives the effect.
|
|
516
|
+
receives the effect. The retry limit is read from
|
|
517
|
+
``config.edge.max_effect_retries`` (default 3).
|
|
419
518
|
"""
|
|
420
519
|
device = await self._require_device(device_id)
|
|
421
520
|
if capability not in device.capabilities:
|
|
@@ -426,12 +525,31 @@ class DeviceGateway:
|
|
|
426
525
|
"device_capabilities": device.capabilities,
|
|
427
526
|
},
|
|
428
527
|
)
|
|
528
|
+
# Resource limit: pending effects per device (EDGE §76).
|
|
529
|
+
if self._config is not None:
|
|
530
|
+
edge_cfg = getattr(self._config, "edge", None)
|
|
531
|
+
if edge_cfg is not None:
|
|
532
|
+
max_pending = getattr(edge_cfg, "max_pending_effects", 0)
|
|
533
|
+
if max_pending > 0:
|
|
534
|
+
pending = await self._store.pending_effects(device_id)
|
|
535
|
+
if len(pending) >= max_pending:
|
|
536
|
+
raise PayloadTooLargeError(
|
|
537
|
+
f"Device '{device_id}' has {len(pending)} pending effects "
|
|
538
|
+
f"(limit {max_pending})"
|
|
539
|
+
)
|
|
540
|
+
max_retries = 3
|
|
541
|
+
if self._config is not None:
|
|
542
|
+
try:
|
|
543
|
+
max_retries = int(self._config.edge.max_effect_retries)
|
|
544
|
+
except (AttributeError, TypeError, ValueError):
|
|
545
|
+
pass
|
|
429
546
|
delivery = EffectDelivery(
|
|
430
547
|
effect_id=effect_id,
|
|
431
548
|
execution_id=execution_id,
|
|
432
549
|
device_id=device_id,
|
|
433
550
|
capability=capability,
|
|
434
551
|
payload=payload,
|
|
552
|
+
max_retries=max_retries,
|
|
435
553
|
)
|
|
436
554
|
# Idempotent on effect_id — duplicate submissions are no-ops.
|
|
437
555
|
await self._store.add_effect_delivery(delivery)
|
|
@@ -449,18 +567,39 @@ class DeviceGateway:
|
|
|
449
567
|
async def pending_effects(
|
|
450
568
|
self, device_id: str, ctx: AuthenticatedDeviceContext
|
|
451
569
|
) -> list[EffectDelivery]:
|
|
452
|
-
"""Retrieve pending effects for a device
|
|
570
|
+
"""Retrieve and claim pending effects for a device.
|
|
571
|
+
|
|
572
|
+
Device isolation is enforced — a device can only list its own
|
|
573
|
+
effects. Each effect is atomically claimed (``pending →
|
|
574
|
+
delivering``) so concurrent HTTP polls never receive the same
|
|
575
|
+
effect twice.
|
|
576
|
+
"""
|
|
453
577
|
if device_id != ctx.device_id:
|
|
454
578
|
raise AuthorizationFailedError("Devices may only list their own effects")
|
|
455
579
|
deliveries = await self._store.pending_effects(device_id)
|
|
580
|
+
claimed: list[EffectDelivery] = []
|
|
456
581
|
for d in deliveries:
|
|
457
|
-
await self._store.
|
|
582
|
+
if await self._store.claim_effect(device_id, d.effect_id):
|
|
583
|
+
claimed.append(d)
|
|
458
584
|
await self._emit(
|
|
459
585
|
"device.effect.delivered",
|
|
460
|
-
{"device_id": device_id, "count": len(
|
|
586
|
+
{"device_id": device_id, "count": len(claimed)},
|
|
461
587
|
trace_id=None,
|
|
462
588
|
)
|
|
463
|
-
return
|
|
589
|
+
return claimed
|
|
590
|
+
|
|
591
|
+
async def retry_effects(self, device_id: str) -> int:
|
|
592
|
+
"""Reset stale ``delivering`` effects back to ``pending`` for retry.
|
|
593
|
+
|
|
594
|
+
Returns the number of effects that were reset or marked as
|
|
595
|
+
``delivery_failed`` (exceeded max retries).
|
|
596
|
+
"""
|
|
597
|
+
stale = await self._store.stale_deliveries(device_id)
|
|
598
|
+
retried = 0
|
|
599
|
+
for d in stale:
|
|
600
|
+
if await self._store.retry_effect(d.effect_id):
|
|
601
|
+
retried += 1
|
|
602
|
+
return retried
|
|
464
603
|
|
|
465
604
|
# ------------------------------------------------------------------
|
|
466
605
|
# Sessions & revocation
|
|
@@ -74,9 +74,28 @@ def _envelope_response(message: Any) -> JSONResponse:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def _make_enrollments_endpoint(gateway: DeviceGateway):
|
|
77
|
-
"""POST /v1/edge/enrollments — issue a single-use enrollment key.
|
|
77
|
+
"""POST /v1/edge/enrollments — issue a single-use enrollment key.
|
|
78
|
+
|
|
79
|
+
Protected by ``X-Admin-Token`` when
|
|
80
|
+
``config.edge.enrollment_auth_required`` is set (Sprint 23.1).
|
|
81
|
+
"""
|
|
78
82
|
|
|
79
83
|
async def enrollments(request: Request) -> Response:
|
|
84
|
+
# Enrollment auth guard (Sprint 23.1).
|
|
85
|
+
config = getattr(gateway, "_config", None)
|
|
86
|
+
edge_cfg = getattr(config, "edge", None) if config else None
|
|
87
|
+
if edge_cfg is not None and getattr(
|
|
88
|
+
edge_cfg, "enrollment_auth_required", False
|
|
89
|
+
):
|
|
90
|
+
admin_token = getattr(edge_cfg, "enrollment_admin_token", "")
|
|
91
|
+
if admin_token:
|
|
92
|
+
provided = request.headers.get("x-admin-token", "")
|
|
93
|
+
if provided != admin_token:
|
|
94
|
+
return _error_to_response(
|
|
95
|
+
AuthenticationFailedError(
|
|
96
|
+
"Missing or invalid admin token for enrollment"
|
|
97
|
+
)
|
|
98
|
+
)
|
|
80
99
|
body = await _json_body(request)
|
|
81
100
|
raw_key = await create_enrollment(
|
|
82
101
|
gateway.store,
|
|
@@ -151,7 +170,12 @@ def _make_auth_endpoint(gateway: DeviceGateway):
|
|
|
151
170
|
|
|
152
171
|
|
|
153
172
|
def _make_device_endpoint(gateway: DeviceGateway, message_type: EdgeMessageType):
|
|
154
|
-
"""Shared factory for authenticated device → runtime endpoints.
|
|
173
|
+
"""Shared factory for stateless authenticated device → runtime endpoints.
|
|
174
|
+
|
|
175
|
+
Each HTTP request authenticates independently via
|
|
176
|
+
``gateway.authenticate_request()`` — no persistent session is
|
|
177
|
+
created. This matches HTTP's stateless nature.
|
|
178
|
+
"""
|
|
155
179
|
|
|
156
180
|
async def handler(request: Request) -> Response:
|
|
157
181
|
credential = _credential_from_request(request)
|
|
@@ -161,7 +185,7 @@ def _make_device_endpoint(gateway: DeviceGateway, message_type: EdgeMessageType)
|
|
|
161
185
|
)
|
|
162
186
|
body = await _json_body(request)
|
|
163
187
|
try:
|
|
164
|
-
ctx
|
|
188
|
+
ctx = await gateway.authenticate_request(
|
|
165
189
|
credential, transport=TransportKind.HTTP
|
|
166
190
|
)
|
|
167
191
|
except EdgeError as e:
|
|
@@ -171,6 +195,7 @@ def _make_device_endpoint(gateway: DeviceGateway, message_type: EdgeMessageType)
|
|
|
171
195
|
message_type,
|
|
172
196
|
device_id=ctx.device_id,
|
|
173
197
|
payload=body,
|
|
198
|
+
message_id=body.pop("message_id", None),
|
|
174
199
|
trace_id=request.headers.get("x-trace-id"),
|
|
175
200
|
)
|
|
176
201
|
try:
|
|
@@ -196,7 +221,7 @@ def _make_effects_endpoint(gateway: DeviceGateway):
|
|
|
196
221
|
AuthenticationFailedError("Missing device credential")
|
|
197
222
|
)
|
|
198
223
|
try:
|
|
199
|
-
ctx
|
|
224
|
+
ctx = await gateway.authenticate_request(
|
|
200
225
|
credential, transport=TransportKind.HTTP
|
|
201
226
|
)
|
|
202
227
|
except EdgeError as e:
|
|
@@ -238,7 +263,7 @@ def _make_effect_ack_endpoint(gateway: DeviceGateway):
|
|
|
238
263
|
effect_id = str(request.path_params.get("effect_id", ""))
|
|
239
264
|
body.setdefault("effect_id", effect_id)
|
|
240
265
|
try:
|
|
241
|
-
ctx
|
|
266
|
+
ctx = await gateway.authenticate_request(
|
|
242
267
|
credential, transport=TransportKind.HTTP
|
|
243
268
|
)
|
|
244
269
|
except EdgeError as e:
|
|
@@ -9,6 +9,10 @@ QoS 1 (at-least-once) is deliberate: the stable ``message_id`` /
|
|
|
9
9
|
``effect_id`` provide application-level idempotency, which remains
|
|
10
10
|
required regardless of QoS (EDGE §37).
|
|
11
11
|
|
|
12
|
+
MQTT is session-oriented: AUTH establishes a persistent context keyed
|
|
13
|
+
by ``device_id``. Subsequent messages reuse the stored context. Only
|
|
14
|
+
AUTH is allowed before a session is established (Sprint 23.1).
|
|
15
|
+
|
|
12
16
|
The paho-mqtt dependency is optional (``voodoo[edge]`` extra); importing
|
|
13
17
|
this module without it raises a clear ImportError.
|
|
14
18
|
"""
|
|
@@ -22,7 +26,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
22
26
|
|
|
23
27
|
from voodoo.edge.errors import TransportError
|
|
24
28
|
from voodoo.edge.gateway import DeviceGateway
|
|
25
|
-
from voodoo.edge.models import TransportKind
|
|
29
|
+
from voodoo.edge.models import AuthenticatedDeviceContext, TransportKind
|
|
26
30
|
from voodoo.edge.protocol import EdgeMessageType, make_message
|
|
27
31
|
|
|
28
32
|
if TYPE_CHECKING:
|
|
@@ -130,6 +134,8 @@ class EdgeMQTTTransport:
|
|
|
130
134
|
self._connected = asyncio.Event()
|
|
131
135
|
# Per-device subscriptions for outbound effects.
|
|
132
136
|
self._device_queues: dict[str, asyncio.Queue[Any]] = {}
|
|
137
|
+
# MQTT session registry — AUTH establishes persistent context.
|
|
138
|
+
self._sessions: dict[str, AuthenticatedDeviceContext] = {}
|
|
133
139
|
|
|
134
140
|
# -- lifecycle ----------------------------------------------------------
|
|
135
141
|
|
|
@@ -168,6 +174,14 @@ class EdgeMQTTTransport:
|
|
|
168
174
|
def connected(self) -> bool:
|
|
169
175
|
return self._client is not None and self._connected.is_set()
|
|
170
176
|
|
|
177
|
+
def session_for(self, device_id: str) -> AuthenticatedDeviceContext | None:
|
|
178
|
+
"""Return the authenticated session for a device, if any."""
|
|
179
|
+
return self._sessions.get(device_id)
|
|
180
|
+
|
|
181
|
+
def disconnect_device(self, device_id: str) -> None:
|
|
182
|
+
"""Remove a device's MQTT session (e.g. on revoke or disconnect)."""
|
|
183
|
+
self._sessions.pop(device_id, None)
|
|
184
|
+
|
|
171
185
|
# -- outbound: effects to devices ---------------------------------------
|
|
172
186
|
|
|
173
187
|
async def publish_effect(self, device_id: str, effect: dict[str, Any]) -> None:
|
|
@@ -215,12 +229,19 @@ class EdgeMQTTTransport:
|
|
|
215
229
|
)
|
|
216
230
|
|
|
217
231
|
async def _handle_inbox(self, topic: str, payload: str) -> None:
|
|
218
|
-
"""Route a decoded inbox message through the shared gateway.
|
|
232
|
+
"""Route a decoded inbox message through the shared gateway.
|
|
233
|
+
|
|
234
|
+
Sprint 23.1 hardening:
|
|
235
|
+
- Topic identity binding: topic device_id must match message device_id.
|
|
236
|
+
- Pre-AUTH rejection: only AUTH allowed before session is established.
|
|
237
|
+
- Session registry: AUTH stores context; subsequent messages reuse it.
|
|
238
|
+
- Topic authorization: device can only publish to its own namespace.
|
|
239
|
+
"""
|
|
219
240
|
parsed = parse_topic(topic)
|
|
220
241
|
if parsed is None:
|
|
221
242
|
logger.warning("Ignoring malformed topic %s", topic)
|
|
222
243
|
return
|
|
223
|
-
|
|
244
|
+
topic_device_id, kind = parsed
|
|
224
245
|
expected_type = _INBOX_TOPICS.get(kind)
|
|
225
246
|
if expected_type is None:
|
|
226
247
|
logger.warning("Ignoring unknown topic kind %s", kind)
|
|
@@ -230,10 +251,73 @@ class EdgeMQTTTransport:
|
|
|
230
251
|
except json.JSONDecodeError:
|
|
231
252
|
logger.warning("Malformed JSON on %s", topic)
|
|
232
253
|
return
|
|
254
|
+
|
|
255
|
+
if not self._validate_message(raw, topic_device_id, kind, expected_type):
|
|
256
|
+
return
|
|
257
|
+
|
|
233
258
|
# Normalize into the canonical envelope typed for the topic kind.
|
|
234
259
|
raw.setdefault("type", expected_type.value)
|
|
235
|
-
raw.setdefault("device_id",
|
|
260
|
+
raw.setdefault("device_id", topic_device_id)
|
|
236
261
|
try:
|
|
237
|
-
await self._gateway.handle_message(
|
|
262
|
+
response = await self._gateway.handle_message(
|
|
263
|
+
raw, transport=TransportKind.MQTT
|
|
264
|
+
)
|
|
238
265
|
except Exception as e: # noqa: BLE001 — broker handler must never crash
|
|
239
266
|
logger.warning("Gateway rejected message on %s: %s", topic, e)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# AUTH success → store session context for subsequent messages.
|
|
270
|
+
if expected_type is EdgeMessageType.AUTH:
|
|
271
|
+
self._register_session(response, topic_device_id)
|
|
272
|
+
|
|
273
|
+
def _validate_message(
|
|
274
|
+
self,
|
|
275
|
+
raw: dict[str, Any],
|
|
276
|
+
topic_device_id: str,
|
|
277
|
+
kind: str,
|
|
278
|
+
expected_type: EdgeMessageType,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Validate topic/message identity and pre-AUTH rules. Returns True if OK."""
|
|
281
|
+
message_device_id = raw.get("device_id", "")
|
|
282
|
+
|
|
283
|
+
# Topic identity binding: topic device_id must match message device_id.
|
|
284
|
+
if message_device_id and message_device_id != topic_device_id:
|
|
285
|
+
logger.warning(
|
|
286
|
+
"Rejecting %s: message device_id '%s' != topic device_id '%s'",
|
|
287
|
+
kind,
|
|
288
|
+
message_device_id,
|
|
289
|
+
topic_device_id,
|
|
290
|
+
)
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
# Pre-AUTH rejection: only AUTH messages allowed before session.
|
|
294
|
+
if expected_type is not EdgeMessageType.AUTH:
|
|
295
|
+
if topic_device_id not in self._sessions:
|
|
296
|
+
logger.warning(
|
|
297
|
+
"Rejecting %s from %s: no authenticated session",
|
|
298
|
+
kind,
|
|
299
|
+
topic_device_id,
|
|
300
|
+
)
|
|
301
|
+
return False
|
|
302
|
+
# Topic authorization: device can only publish to own namespace.
|
|
303
|
+
session = self._sessions[topic_device_id]
|
|
304
|
+
if message_device_id and message_device_id != session.device_id:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"Rejecting %s: message device_id '%s' != session device '%s'",
|
|
307
|
+
kind,
|
|
308
|
+
message_device_id,
|
|
309
|
+
session.device_id,
|
|
310
|
+
)
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
def _register_session(self, response: Any, topic_device_id: str) -> None:
|
|
316
|
+
"""Store the MQTT session context after successful AUTH."""
|
|
317
|
+
session_id = response.payload.get("session_id", "")
|
|
318
|
+
device_id = response.payload.get("device_id", topic_device_id)
|
|
319
|
+
if session_id:
|
|
320
|
+
ctx = self._gateway.context_for_session(session_id)
|
|
321
|
+
if ctx is not None:
|
|
322
|
+
self._sessions[device_id] = ctx
|
|
323
|
+
logger.info("MQTT session established for %s", device_id)
|