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.
Files changed (247) hide show
  1. {voodoo_framework-2.6.0/src/voodoo_framework.egg-info → voodoo_framework-2.6.1}/PKG-INFO +1 -1
  2. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/__init__.py +1 -1
  3. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/config.py +5 -0
  4. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/__init__.py +10 -0
  5. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/auth.py +7 -0
  6. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/errors.py +45 -0
  7. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/gateway.py +152 -13
  8. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/http.py +30 -5
  9. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/mqtt.py +89 -5
  10. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/simulator.py +65 -0
  11. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/store.py +212 -8
  12. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1/src/voodoo_framework.egg-info}/PKG-INFO +1 -1
  13. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/SOURCES.txt +2 -0
  14. voodoo_framework-2.6.1/tests/test_edge_e2e.py +186 -0
  15. voodoo_framework-2.6.1/tests/test_edge_gateway.py +1002 -0
  16. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_http.py +38 -0
  17. voodoo_framework-2.6.1/tests/test_edge_security.py +399 -0
  18. voodoo_framework-2.6.0/tests/test_edge_gateway.py +0 -417
  19. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/LICENSE +0 -0
  20. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/README.md +0 -0
  21. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/pyproject.toml +0 -0
  22. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/setup.cfg +0 -0
  23. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/__init__.py +0 -0
  24. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/capabilities.py +0 -0
  25. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/registry.py +0 -0
  26. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/tailwind.py +0 -0
  27. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/adapters/voodoo_css.py +0 -0
  28. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agent.py +0 -0
  29. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/__init__.py +0 -0
  30. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/models.py +0 -0
  31. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/agents/registry.py +0 -0
  32. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/__init__.py +0 -0
  33. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/agent.py +0 -0
  34. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/__init__.py +0 -0
  35. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/anthropic.py +0 -0
  36. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/gemini.py +0 -0
  37. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/mock.py +0 -0
  38. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/ollama.py +0 -0
  39. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/providers/openai.py +0 -0
  40. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/tools/__init__.py +0 -0
  41. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ai/tools/registry.py +0 -0
  42. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/api.py +0 -0
  43. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/__init__.py +0 -0
  44. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/cookies.py +0 -0
  45. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/guards.py +0 -0
  46. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/jwt.py +0 -0
  47. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/middleware.py +0 -0
  48. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/passwords.py +0 -0
  49. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/auth/user.py +0 -0
  50. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/__init__.py +0 -0
  51. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/__main__.py +0 -0
  52. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/agents.py +0 -0
  53. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/ai.py +0 -0
  54. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/approvals.py +0 -0
  55. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/auth.py +0 -0
  56. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/create.py +0 -0
  57. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/dev.py +0 -0
  58. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/doctor.py +0 -0
  59. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/executions.py +0 -0
  60. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/generate.py +0 -0
  61. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/inspect.py +0 -0
  62. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/new.py +0 -0
  63. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/objects.py +0 -0
  64. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/protocol.py +0 -0
  65. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/recover.py +0 -0
  66. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/routes.py +0 -0
  67. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/scaffolding.py +0 -0
  68. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/schedules.py +0 -0
  69. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/status.py +0 -0
  70. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/tasks.py +0 -0
  71. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/terminal.py +0 -0
  72. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/theme.py +0 -0
  73. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/version.py +0 -0
  74. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/cli/workers_cmd.py +0 -0
  75. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/components.py +0 -0
  76. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/__init__.py +0 -0
  77. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/app.py +0 -0
  78. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/errors.py +0 -0
  79. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/events.py +0 -0
  80. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/render.py +0 -0
  81. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/routing.py +0 -0
  82. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/sitemap.py +0 -0
  83. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/core/state.py +0 -0
  84. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/__init__.py +0 -0
  85. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/base.py +0 -0
  86. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/data/model.py +0 -0
  87. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/models.py +0 -0
  88. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/edge/protocol.py +0 -0
  89. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/i18n.py +0 -0
  90. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mcp/__init__.py +0 -0
  91. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mcp/client.py +0 -0
  92. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/__init__.py +0 -0
  93. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/interfaces.py +0 -0
  94. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/memory/sqlite.py +0 -0
  95. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/__init__.py +0 -0
  96. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/client.py +0 -0
  97. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/mesh/event_bus.py +0 -0
  98. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/__init__.py +0 -0
  99. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/capability.py +0 -0
  100. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/compute.py +0 -0
  101. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/constraint.py +0 -0
  102. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/effect.py +0 -0
  103. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/intent.py +0 -0
  104. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/resource.py +0 -0
  105. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/state.py +0 -0
  106. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/primitives/time.py +0 -0
  107. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/__init__.py +0 -0
  108. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/export.py +0 -0
  109. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/protocol/schemas.py +0 -0
  110. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/py.typed +0 -0
  111. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/queue.py +0 -0
  112. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/routing/api.py +0 -0
  113. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/routing/pages.py +0 -0
  114. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/__init__.py +0 -0
  115. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/adaptive.py +0 -0
  116. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/capability.py +0 -0
  117. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/constraint.py +0 -0
  118. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/context.py +0 -0
  119. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/engine.py +0 -0
  120. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/errors.py +0 -0
  121. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/execution.py +0 -0
  122. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/graph.py +0 -0
  123. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/human.py +0 -0
  124. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/persistence.py +0 -0
  125. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/planner.py +0 -0
  126. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/scheduler.py +0 -0
  127. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/task.py +0 -0
  128. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/runtime/workflow.py +0 -0
  129. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/schedule.py +0 -0
  130. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/__init__.py +0 -0
  131. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/cors.py +0 -0
  132. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/csrf.py +0 -0
  133. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/headers.py +0 -0
  134. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/passwords.py +0 -0
  135. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/rate_limit.py +0 -0
  136. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/redaction.py +0 -0
  137. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/security/secrets.py +0 -0
  138. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/seo.py +0 -0
  139. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/static/client.js +0 -0
  140. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/status.py +0 -0
  141. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/__init__.py +0 -0
  142. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/__init__.py +0 -0
  143. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/interfaces.py +0 -0
  144. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/memory.py +0 -0
  145. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/cache/redis.py +0 -0
  146. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/__init__.py +0 -0
  147. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/interfaces.py +0 -0
  148. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/postgres.py +0 -0
  149. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/database/sqlite.py +0 -0
  150. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/__init__.py +0 -0
  151. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/interfaces.py +0 -0
  152. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/local.py +0 -0
  153. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/postgres.py +0 -0
  154. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/events/sqlite.py +0 -0
  155. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/__init__.py +0 -0
  156. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/migrations.py +0 -0
  157. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/postgres.py +0 -0
  158. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/execution/sqlite.py +0 -0
  159. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/manager.py +0 -0
  160. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/__init__.py +0 -0
  161. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/interfaces.py +0 -0
  162. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/local.py +0 -0
  163. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/objects/s3.py +0 -0
  164. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/__init__.py +0 -0
  165. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/interfaces.py +0 -0
  166. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/memory.py +0 -0
  167. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/postgres.py +0 -0
  168. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/redis.py +0 -0
  169. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/queue/sqlite.py +0 -0
  170. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/scheduler/__init__.py +0 -0
  171. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/storage/scheduler/sqlite.py +0 -0
  172. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/__init__.py +0 -0
  173. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/api.py +0 -0
  174. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/middleware.py +0 -0
  175. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/otlp.py +0 -0
  176. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/telemetry/store.py +0 -0
  177. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/theme.py +0 -0
  178. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/tools/__init__.py +0 -0
  179. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/tools/registry.py +0 -0
  180. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/__init__.py +0 -0
  181. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/component.py +0 -0
  182. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/events.py +0 -0
  183. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/library.py +0 -0
  184. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/markdown.py +0 -0
  185. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/rendering.py +0 -0
  186. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/state.py +0 -0
  187. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/__init__.py +0 -0
  188. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/presets.py +0 -0
  189. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/ui/styles/theme.py +0 -0
  190. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/workers/__init__.py +0 -0
  191. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo/workers/queue.py +0 -0
  192. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/dependency_links.txt +0 -0
  193. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/entry_points.txt +0 -0
  194. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/requires.txt +0 -0
  195. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/src/voodoo_framework.egg-info/top_level.txt +0 -0
  196. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent.py +0 -0
  197. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent_registry.py +0 -0
  198. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_agent_runtime.py +0 -0
  199. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_app.py +0 -0
  200. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_auth.py +0 -0
  201. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_capability_security.py +0 -0
  202. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_checkpoint_resume.py +0 -0
  203. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_chrome.py +0 -0
  204. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_cli.py +0 -0
  205. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_cli_inspect.py +0 -0
  206. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_components.py +0 -0
  207. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_config.py +0 -0
  208. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_contract_api.py +0 -0
  209. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_create.py +0 -0
  210. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_data.py +0 -0
  211. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_design_system.py +0 -0
  212. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_durable_hitl.py +0 -0
  213. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_device.py +0 -0
  214. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_edge_protocol.py +0 -0
  215. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_events.py +0 -0
  216. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_execution_sqlite_store.py +0 -0
  217. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_http_runtime.py +0 -0
  218. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_human.py +0 -0
  219. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_i18n.py +0 -0
  220. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_integration.py +0 -0
  221. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mcp.py +0 -0
  222. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mcp_runtime.py +0 -0
  223. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_memory.py +0 -0
  224. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mesh.py +0 -0
  225. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_mesh_event_bus.py +0 -0
  226. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_model.py +0 -0
  227. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_object_store.py +0 -0
  228. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_observability.py +0 -0
  229. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_persistence.py +0 -0
  230. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_planner_adaptive.py +0 -0
  231. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_primitives.py +0 -0
  232. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_protocol.py +0 -0
  233. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_provider_switching.py +0 -0
  234. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_providers.py +0 -0
  235. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_queue.py +0 -0
  236. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_reactive.py +0 -0
  237. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_runtime.py +0 -0
  238. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_scheduler.py +0 -0
  239. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_security.py +0 -0
  240. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_seo.py +0 -0
  241. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_state.py +0 -0
  242. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_theme.py +0 -0
  243. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_theme_presets.py +0 -0
  244. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_tools.py +0 -0
  245. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_ui.py +0 -0
  246. {voodoo_framework-2.6.0 → voodoo_framework-2.6.1}/tests/test_websocket.py +0 -0
  247. {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.0
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>
@@ -102,7 +102,7 @@ from .ui.styles import (
102
102
  from .ui.styles.theme import Theme, ThemeColors, create_theme
103
103
  from .workers import task
104
104
 
105
- __version__ = "2.6.0"
105
+ __version__ = "2.6.1"
106
106
 
107
107
  __all__ = [
108
108
  # Core runtime
@@ -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, store: DeviceStoreProtocol, engine: ExecutionEngine | None = None
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 without creating Executions;
241
- semantic events become Intents executed by the engine.
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: acknowledge without re-processing.
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
- return make_message(
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
- return make_message(
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 (device isolation enforced)."""
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.mark_effect_delivered(d.effect_id)
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(deliveries)},
586
+ {"device_id": device_id, "count": len(claimed)},
461
587
  trace_id=None,
462
588
  )
463
- return deliveries
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, _session = await gateway.connect(
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, _session = await gateway.connect(
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, _session = await gateway.connect(
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
- device_id, kind = parsed
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", device_id)
260
+ raw.setdefault("device_id", topic_device_id)
236
261
  try:
237
- await self._gateway.handle_message(raw, transport=TransportKind.MQTT)
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)