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.
- package/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +129 -0
- package/README.md +205 -123
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +228 -17
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +187 -0
- package/src/superlocalmemory/cli/main.py +49 -8
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +21 -7
- package/src/superlocalmemory/core/embeddings.py +131 -46
- package/src/superlocalmemory/core/engine.py +41 -22
- package/src/superlocalmemory/core/engine_ingestion.py +359 -43
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -3
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +186 -29
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +148 -3
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +263 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +32 -79
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +221 -47
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +317 -70
- package/src/superlocalmemory/server/routes/behavioral.py +349 -71
- package/src/superlocalmemory/server/routes/brain.py +69 -12
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +438 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +108 -26
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +189 -68
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +289 -118
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +200 -31
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +103 -158
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +247 -89
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +827 -72
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +132 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +136 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +303 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +100 -41
- package/src/superlocalmemory/ui/js/dashboard.js +403 -65
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +871 -0
- package/src/superlocalmemory/ui/js/od-brain.js +816 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +600 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +929 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
- package/src/superlocalmemory/ui/js/od-shell.js +809 -0
- package/src/superlocalmemory/ui/js/od-skills.js +600 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +17 -3
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -23
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -16
- 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}
|