superlocalmemory 3.7.8 → 3.8.1

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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +827 -72
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -0,0 +1,132 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later — see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Dashboard-editable rate limits (task #47).
6
+
7
+ The soak found the loopback write limiter (correctly) returning 429 under
8
+ heavy multi-system load. This exposes the write/read/window ceilings so an
9
+ operator can raise them from the Governance panel:
10
+
11
+ GET /api/v3/ratelimit — current effective limits (+ derived loopback)
12
+ PUT /api/v3/ratelimit — set write/read/window; applied at runtime (no
13
+ restart) and persisted to config.json
14
+
15
+ Runtime apply goes through rate_limiter.set_limits(), which reconfigures every
16
+ registered enforcement limiter live. Persistence reuses config_api's config.json
17
+ helpers; load_persisted_limits() re-applies the saved override at daemon start.
18
+ Admin-only (RBAC MANAGE), matching config_api.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ from fastapi import APIRouter, Request
26
+ from fastapi.responses import JSONResponse
27
+ from pydantic import BaseModel, ConfigDict, Field
28
+
29
+ from superlocalmemory.infra.rate_limiter import (
30
+ _loopback_read,
31
+ _loopback_write,
32
+ get_limits,
33
+ set_limits,
34
+ )
35
+ from superlocalmemory.server.routes.config_api import (
36
+ _read_config,
37
+ _require_admin,
38
+ _update_config,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ router = APIRouter(prefix="/api/v3", tags=["ratelimit"])
44
+
45
+ _CONFIG_KEY = "rate_limit"
46
+ _MIN, _MAX_REQ, _MAX_WINDOW = 1, 100_000, 3600
47
+
48
+
49
+ class RateLimitUpdate(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+ write: int | None = Field(default=None, ge=_MIN, le=_MAX_REQ)
52
+ read: int | None = Field(default=None, ge=_MIN, le=_MAX_REQ)
53
+ window: int | None = Field(default=None, ge=_MIN, le=_MAX_WINDOW)
54
+
55
+
56
+ def _effective() -> dict:
57
+ cur = get_limits()
58
+ return {
59
+ "write": cur["write"],
60
+ "read": cur["read"],
61
+ "window": cur["window"],
62
+ "loopback_write": _loopback_write(cur["write"]),
63
+ "loopback_read": _loopback_read(cur["read"]),
64
+ }
65
+
66
+
67
+ def load_persisted_limits() -> None:
68
+ """Re-apply the persisted rate-limit override at daemon start (fail-open)."""
69
+ try:
70
+ saved = (_read_config().get(_CONFIG_KEY) or {})
71
+ if not isinstance(saved, dict):
72
+ return
73
+ w = saved.get("write")
74
+ r = saved.get("read")
75
+ win = saved.get("window")
76
+ if w is None and r is None and win is None:
77
+ return
78
+ set_limits(write=w, read=r, window=win)
79
+ logger.info("Rate limits restored from config.json: %s", get_limits())
80
+ except Exception as exc: # pragma: no cover - defensive
81
+ logger.debug("load_persisted_limits skipped: %s", exc)
82
+
83
+
84
+ def _require_read(request: Request) -> None:
85
+ from superlocalmemory.access.rbac import Permission
86
+ from superlocalmemory.server.rbac_enforce import require_permission
87
+ from superlocalmemory.server.routes.helpers import get_active_profile
88
+
89
+ require_permission(request, Permission.READ, profile=get_active_profile())
90
+
91
+
92
+ @router.get("/ratelimit")
93
+ def get_ratelimit(request: Request) -> JSONResponse:
94
+ _require_read(request)
95
+ return JSONResponse(_effective())
96
+
97
+
98
+ @router.put("/ratelimit")
99
+ def put_ratelimit(
100
+ request: Request,
101
+ payload: RateLimitUpdate,
102
+ ) -> JSONResponse:
103
+ _require_admin(request)
104
+
105
+ if payload.write is None and payload.read is None and payload.window is None:
106
+ return JSONResponse(
107
+ status_code=422,
108
+ content={"error": "Provide at least one of write, read, window."},
109
+ )
110
+
111
+ current = get_limits()
112
+ requested = {
113
+ "write": payload.write if payload.write is not None else current["write"],
114
+ "read": payload.read if payload.read is not None else current["read"],
115
+ "window": payload.window if payload.window is not None else current["window"],
116
+ }
117
+ # Persist first. A response must never say success for a runtime-only
118
+ # setting that silently disappears after restart.
119
+ try:
120
+ _update_config(
121
+ lambda cfg: cfg.update({_CONFIG_KEY: dict(requested)}),
122
+ )
123
+ except Exception as exc:
124
+ logger.warning("rate-limit persist failed: %s", exc)
125
+ return JSONResponse(
126
+ status_code=500,
127
+ content={"error": "Rate-limit configuration was not persisted."},
128
+ )
129
+
130
+ # Runtime apply reconfigures every live limiter; no restart is required.
131
+ set_limits(**requested)
132
+ return JSONResponse({"success": True, **_effective()})
@@ -0,0 +1,367 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC / teams (C3)
4
+
5
+ """Dashboard-facing RBAC API: login/logout/whoami + user & role administration.
6
+
7
+ Mounted at /api/rbac/*. Every route first proves machine auth (the dashboard
8
+ holds the install token / the operator is on loopback) via
9
+ require_http_mutation_actor, then applies RBAC where relevant:
10
+
11
+ * login/logout/whoami/status — machine auth only (identity bootstrap).
12
+ * user + membership + policy admin — MANAGE permission (owner or an admin).
13
+
14
+ Creating the first user works out of the box: with zero users the caller is the
15
+ implicit owner, who has MANAGE.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import threading
23
+ import time
24
+ from typing import Optional
25
+
26
+ from fastapi import APIRouter, HTTPException, Request, Response
27
+ from pydantic import BaseModel
28
+
29
+ from superlocalmemory.access.rbac import RbacError
30
+ from superlocalmemory.server.rbac_enforce import (
31
+ get_rbac_engine, principal_info, require_manage, resolve_principal,
32
+ )
33
+
34
+ logger = logging.getLogger("superlocalmemory.routes.rbac")
35
+ router = APIRouter(prefix="/api/rbac", tags=["rbac"])
36
+
37
+ _SESSION_COOKIE = "slm_session"
38
+
39
+ # Login throttle: lock an account after repeated failures within a window, to
40
+ # blunt password spraying / brute force (scrypt alone allows ~20 tries/s).
41
+ _LOGIN_MAX_FAILS = 5
42
+ _LOGIN_WINDOW_SEC = 300
43
+ # SEC-M-01: cap tracked usernames so a spray of unique usernames (one failed
44
+ # login each) cannot grow this dict without bound (memory-exhaustion DoS).
45
+ _LOGIN_MAX_TRACKED = 10_000
46
+ _login_fails: dict[str, list] = {}
47
+ _login_lock = threading.Lock()
48
+
49
+
50
+ def _login_blocked(username: str) -> bool:
51
+ now = time.time()
52
+ with _login_lock:
53
+ fails = [t for t in _login_fails.get(username, []) if now - t < _LOGIN_WINDOW_SEC]
54
+ # SEC-M-01: evict entries that decayed to empty instead of parking a
55
+ # zero-length list forever (only successful logins used to pop them).
56
+ if fails:
57
+ _login_fails[username] = fails
58
+ else:
59
+ _login_fails.pop(username, None)
60
+ # Overflow guard: drop the oldest-inserted key if we exceed the cap.
61
+ if len(_login_fails) > _LOGIN_MAX_TRACKED:
62
+ _login_fails.pop(next(iter(_login_fails)), None)
63
+ return len(fails) >= _LOGIN_MAX_FAILS
64
+
65
+
66
+ def _login_note(username: str, ok: bool) -> None:
67
+ with _login_lock:
68
+ if ok:
69
+ _login_fails.pop(username, None)
70
+ else:
71
+ _login_fails.setdefault(username, []).append(time.time())
72
+
73
+
74
+ def _is_browser(request: Request) -> bool:
75
+ h = request.headers
76
+ return bool(h.get("referer") or h.get("origin") or h.get("cookie")
77
+ or h.get("sec-fetch-mode"))
78
+
79
+
80
+ def _cookie_secure(request: Request) -> bool:
81
+ return (request.headers.get("x-forwarded-proto", "").lower() == "https"
82
+ or os.environ.get("SLM_DASHBOARD_HTTPS") == "1")
83
+
84
+
85
+ # -- models --
86
+
87
+ class LoginRequest(BaseModel):
88
+ username: str
89
+ password: str
90
+
91
+
92
+ class CreateUserRequest(BaseModel):
93
+ username: str
94
+ password: str
95
+ display_name: str = ""
96
+ role: Optional[str] = None # optional membership on the active profile
97
+ profile_id: Optional[str] = None
98
+
99
+
100
+ class UpdateUserRequest(BaseModel):
101
+ display_name: Optional[str] = None
102
+ password: Optional[str] = None
103
+ status: Optional[str] = None
104
+
105
+
106
+ class MemberRequest(BaseModel):
107
+ user_id: str
108
+ role: str
109
+ profile_id: Optional[str] = None
110
+
111
+
112
+ class RemoveMemberRequest(BaseModel):
113
+ user_id: str
114
+ profile_id: Optional[str] = None
115
+
116
+
117
+ class PolicyRequest(BaseModel):
118
+ require_login: bool
119
+
120
+
121
+ # -- helpers --
122
+
123
+ def _machine_guard(request: Request) -> None:
124
+ """Prove machine auth (operator / dashboard) before any RBAC route."""
125
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
126
+
127
+ broker = getattr(request.app.state, "mesh_broker", None)
128
+ require_http_mutation_actor(
129
+ request,
130
+ getattr(request.app.state, "daemon_descriptor", None),
131
+ actor_kind="rbac-route",
132
+ mesh_secret=getattr(broker, "_shared_secret", None) if broker else None,
133
+ )
134
+
135
+
136
+ def _engine(request: Request):
137
+ rbac = get_rbac_engine(request.app.state)
138
+ if rbac is None:
139
+ raise HTTPException(503, detail="RBAC subsystem not initialized")
140
+ return rbac
141
+
142
+
143
+ def _active_profile() -> str:
144
+ from superlocalmemory.server.routes.helpers import get_active_profile
145
+
146
+ return get_active_profile()
147
+
148
+
149
+ def _session_token(request: Request) -> str:
150
+ return (request.headers.get("X-SLM-User-Session", "")
151
+ or (request.cookies.get(_SESSION_COOKIE, "") or ""))
152
+
153
+
154
+ def _require_authority_over_user(request: Request, target_user_id: str) -> None:
155
+ """Reject modifying a user the caller has no authority over (IDOR guard).
156
+
157
+ The machine owner is root. A logged-in admin may only act on a user who
158
+ shares at least one workspace on which the admin holds MANAGE.
159
+ """
160
+ from superlocalmemory.access.rbac import Permission
161
+ from superlocalmemory.server.rbac_enforce import resolve_principal
162
+
163
+ principal = resolve_principal(request)
164
+ if principal["kind"] == "owner":
165
+ return
166
+ rbac = _engine(request)
167
+ target_profiles = rbac.list_user_profiles(target_user_id)
168
+ if any(rbac.has_permission(principal["user_id"], p["profile_id"], Permission.MANAGE)
169
+ for p in target_profiles):
170
+ return
171
+ raise HTTPException(403, detail="You have no authority over this user.")
172
+
173
+
174
+ # -- identity --
175
+
176
+ @router.post("/login")
177
+ async def login(req: LoginRequest, request: Request, response: Response):
178
+ _machine_guard(request)
179
+ uname = (req.username or "").strip()
180
+ if _login_blocked(uname):
181
+ raise HTTPException(429, detail="Too many attempts. Try again later.")
182
+ rbac = _engine(request)
183
+ user = rbac.verify_credentials(req.username, req.password)
184
+ if not user:
185
+ _login_note(uname, ok=False)
186
+ raise HTTPException(401, detail="Invalid username or password")
187
+ _login_note(uname, ok=True)
188
+ token = rbac.create_session(user["user_id"])
189
+ # HttpOnly cookie so dashboard JS never has to hold the session token;
190
+ # Secure when served over HTTPS (behind a TLS proxy).
191
+ response.set_cookie(
192
+ _SESSION_COOKIE, token, httponly=True, samesite="strict", path="/",
193
+ secure=_cookie_secure(request),
194
+ )
195
+ # Only echo the raw token to non-browser (CLI) clients that cannot use the
196
+ # cookie — browsers rely on the HttpOnly cookie and must not see it in the
197
+ # response body (proxy logs / devtools capture).
198
+ body = {"ok": True, "user": user}
199
+ if not _is_browser(request):
200
+ body["token"] = token
201
+ return body
202
+
203
+
204
+ @router.post("/logout")
205
+ async def logout(request: Request, response: Response):
206
+ _machine_guard(request)
207
+ rbac = _engine(request)
208
+ token = _session_token(request)
209
+ if token:
210
+ rbac.revoke_session(token)
211
+ response.delete_cookie(_SESSION_COOKIE, path="/")
212
+ return {"ok": True}
213
+
214
+
215
+ @router.get("/whoami")
216
+ async def whoami(request: Request):
217
+ _machine_guard(request)
218
+ return principal_info(request)
219
+
220
+
221
+ @router.get("/status")
222
+ async def status(request: Request):
223
+ _machine_guard(request)
224
+ rbac = _engine(request)
225
+ return {
226
+ "rbac_active": rbac.user_count() > 0,
227
+ "require_login": rbac.require_login(),
228
+ "user_count": rbac.user_count(),
229
+ }
230
+
231
+
232
+ # -- user administration (MANAGE) --
233
+
234
+ @router.get("/users")
235
+ async def list_users(request: Request):
236
+ _machine_guard(request)
237
+ principal = require_manage(request)
238
+ rbac = _engine(request)
239
+ all_users = rbac.list_users()
240
+ if principal.get("kind") == "owner":
241
+ return {"users": all_users}
242
+ # A workspace admin only sees users who share a workspace they MANAGE
243
+ # (usernames/display names are PII — don't leak other tenants' rosters).
244
+ from superlocalmemory.access.rbac import Permission
245
+ mgr = {p["profile_id"] for p in rbac.list_user_profiles(principal["user_id"])
246
+ if rbac.has_permission(principal["user_id"], p["profile_id"], Permission.MANAGE)}
247
+ visible = [
248
+ u for u in all_users
249
+ if u["user_id"] == principal["user_id"]
250
+ or any(m["profile_id"] in mgr for m in rbac.list_user_profiles(u["user_id"]))
251
+ ]
252
+ return {"users": visible}
253
+
254
+
255
+ @router.post("/users")
256
+ async def create_user(req: CreateUserRequest, request: Request):
257
+ _machine_guard(request)
258
+ principal = require_manage(request)
259
+ rbac = _engine(request)
260
+ # Granting a role on a workspace requires MANAGE on THAT workspace.
261
+ if req.role:
262
+ require_manage(request, profile=req.profile_id or _active_profile())
263
+ try:
264
+ user = rbac.create_user(
265
+ req.username, req.password, display_name=req.display_name,
266
+ created_by=principal["username"],
267
+ )
268
+ if req.role:
269
+ rbac.set_membership(
270
+ req.profile_id or _active_profile(), user["user_id"],
271
+ req.role, added_by=principal["username"],
272
+ )
273
+ except RbacError as e:
274
+ # SEC-H-02 carve-out: RbacError messages are intentional domain
275
+ # validation messages (e.g. "Username already exists") and are safe
276
+ # to surface to the admin console. Log for audit trail.
277
+ logger.warning("rbac create_user rejected: %s", e)
278
+ raise HTTPException(400, detail=str(e))
279
+ return {"ok": True, "user": user}
280
+
281
+
282
+ @router.patch("/users/{user_id}")
283
+ async def update_user(user_id: str, req: UpdateUserRequest, request: Request):
284
+ _machine_guard(request)
285
+ require_manage(request)
286
+ _require_authority_over_user(request, user_id)
287
+ rbac = _engine(request)
288
+ try:
289
+ if req.display_name is not None or req.status is not None:
290
+ if req.status is not None:
291
+ rbac.set_status(user_id, req.status)
292
+ if req.display_name is not None:
293
+ # display_name update via direct engine call
294
+ conn = rbac._conn()
295
+ try:
296
+ conn.execute(
297
+ "UPDATE rbac_users SET display_name=? WHERE user_id=?",
298
+ (req.display_name, user_id),
299
+ )
300
+ conn.commit()
301
+ finally:
302
+ conn.close()
303
+ if req.password is not None:
304
+ rbac.set_password(user_id, req.password)
305
+ except RbacError as e:
306
+ logger.warning("rbac update_user rejected: %s", e)
307
+ raise HTTPException(400, detail=str(e))
308
+ return {"ok": True, "user": rbac.get_user(user_id)}
309
+
310
+
311
+ @router.delete("/users/{user_id}")
312
+ async def delete_user(user_id: str, request: Request):
313
+ _machine_guard(request)
314
+ require_manage(request)
315
+ _require_authority_over_user(request, user_id)
316
+ try:
317
+ _engine(request).delete_user(user_id)
318
+ except RbacError as e:
319
+ logger.warning("rbac delete_user not found: %s", e)
320
+ raise HTTPException(404, detail=str(e))
321
+ return {"ok": True}
322
+
323
+
324
+ # -- membership administration (MANAGE) --
325
+
326
+ @router.get("/members")
327
+ async def list_members(request: Request, profile_id: Optional[str] = None):
328
+ _machine_guard(request)
329
+ prof = profile_id or _active_profile()
330
+ # MANAGE on the TARGET workspace, not merely the active one — otherwise an
331
+ # admin of one workspace could enumerate another's members.
332
+ require_manage(request, profile=prof)
333
+ return {"profile_id": prof, "members": _engine(request).list_members(prof)}
334
+
335
+
336
+ @router.post("/members")
337
+ async def set_member(req: MemberRequest, request: Request):
338
+ _machine_guard(request)
339
+ prof = req.profile_id or _active_profile()
340
+ principal = require_manage(request, profile=prof)
341
+ rbac = _engine(request)
342
+ try:
343
+ m = rbac.set_membership(prof, req.user_id, req.role,
344
+ added_by=principal["username"])
345
+ except RbacError as e:
346
+ logger.warning("rbac set_membership rejected: %s", e)
347
+ raise HTTPException(400, detail=str(e))
348
+ return {"ok": True, "membership": m}
349
+
350
+
351
+ @router.delete("/members")
352
+ async def remove_member(req: RemoveMemberRequest, request: Request):
353
+ _machine_guard(request)
354
+ prof = req.profile_id or _active_profile()
355
+ require_manage(request, profile=prof)
356
+ _engine(request).remove_membership(prof, req.user_id)
357
+ return {"ok": True}
358
+
359
+
360
+ # -- policy (MANAGE) --
361
+
362
+ @router.post("/policy")
363
+ async def set_policy(req: PolicyRequest, request: Request):
364
+ _machine_guard(request)
365
+ require_manage(request)
366
+ _engine(request).set_require_login(req.require_login)
367
+ return {"ok": True, "require_login": req.require_login}