superlocalmemory 3.7.8 → 3.8.0
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 +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- 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/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- 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-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- 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-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -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 +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- 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/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 +5 -43
- 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 +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- 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/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- 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 +2 -0
- 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 +35 -11
- 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/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- 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/reward_proxy.py +42 -9
- 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 +243 -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 +21 -49
- 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 +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- 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 +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- 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/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 +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- 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 +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- 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 +14 -14
- 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 +95 -15
- 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 +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- 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 +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -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/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- 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 +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- 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 +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -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 +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -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 +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -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 +2 -2
- 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/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -28,11 +28,18 @@ from .helpers import (
|
|
|
28
28
|
from superlocalmemory.server.profile_runtime import (
|
|
29
29
|
commit_daemon_profile_switch,
|
|
30
30
|
get_profile_runtime,
|
|
31
|
+
TransitionDrainTimeout,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
logger = logging.getLogger("superlocalmemory.routes.profiles")
|
|
34
35
|
router = APIRouter()
|
|
35
36
|
|
|
37
|
+
|
|
38
|
+
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
39
|
+
"""SEC-H-02: log full traceback server-side; return a generic message to the client."""
|
|
40
|
+
logger.exception("profiles route error")
|
|
41
|
+
return HTTPException(status_code=500, detail=detail)
|
|
42
|
+
|
|
36
43
|
# WebSocket manager reference (set by ui_server.py at startup)
|
|
37
44
|
ws_manager = None
|
|
38
45
|
|
|
@@ -85,8 +92,8 @@ async def list_profiles(request: Request):
|
|
|
85
92
|
"total_profiles": len(profiles),
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
except Exception
|
|
89
|
-
raise
|
|
95
|
+
except Exception:
|
|
96
|
+
raise _internal_error("Profile list error")
|
|
90
97
|
|
|
91
98
|
|
|
92
99
|
@router.post("/api/profiles/{name}/switch")
|
|
@@ -112,17 +119,36 @@ async def switch_profile(name: str, request: Request):
|
|
|
112
119
|
source_agent_id="http-profile-switch",
|
|
113
120
|
profile_id=name,
|
|
114
121
|
)
|
|
122
|
+
# RBAC (C4): a logged-in user may only activate a workspace they belong
|
|
123
|
+
# to — this is the read boundary. Switching sets the active tenant for
|
|
124
|
+
# subsequent reads, so a non-member must not be able to enter it. The
|
|
125
|
+
# machine owner (no user session) switches freely.
|
|
126
|
+
from superlocalmemory.server.rbac_enforce import resolve_principal
|
|
127
|
+
_principal = resolve_principal(request)
|
|
128
|
+
if _principal.get("kind") == "user":
|
|
129
|
+
_rbac = getattr(request.app.state, "rbac", None)
|
|
130
|
+
if _rbac is not None and _rbac.get_role(_principal["user_id"], name) is None:
|
|
131
|
+
raise HTTPException(
|
|
132
|
+
status_code=403,
|
|
133
|
+
detail="You are not a member of this workspace.",
|
|
134
|
+
)
|
|
115
135
|
runtime = get_profile_runtime(request.app.state)
|
|
116
136
|
previous = runtime.snapshot.profile_id
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
try:
|
|
138
|
+
snapshot = await asyncio.to_thread(
|
|
139
|
+
runtime.transition,
|
|
140
|
+
name,
|
|
141
|
+
lambda prior, target: commit_daemon_profile_switch(
|
|
142
|
+
request.app.state,
|
|
143
|
+
prior,
|
|
144
|
+
target,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
except TransitionDrainTimeout as exc:
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=503,
|
|
150
|
+
detail=str(exc),
|
|
151
|
+
)
|
|
126
152
|
|
|
127
153
|
count = _get_memory_count(name)
|
|
128
154
|
|
|
@@ -143,8 +169,8 @@ async def switch_profile(name: str, request: Request):
|
|
|
143
169
|
|
|
144
170
|
except HTTPException:
|
|
145
171
|
raise
|
|
146
|
-
except Exception
|
|
147
|
-
raise
|
|
172
|
+
except Exception:
|
|
173
|
+
raise _internal_error("Profile switch error")
|
|
148
174
|
|
|
149
175
|
|
|
150
176
|
@router.post("/api/profiles/create")
|
|
@@ -166,18 +192,34 @@ async def create_profile(body: ProfileSwitch, request: Request):
|
|
|
166
192
|
operation="update",
|
|
167
193
|
source_agent_id="http-profile-create",
|
|
168
194
|
)
|
|
195
|
+
# RBAC (C3): creating a tenant is an administrative action.
|
|
196
|
+
from superlocalmemory.access.rbac import Permission as _Perm
|
|
197
|
+
from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
|
|
198
|
+
_principal = _rbac_manage(request)
|
|
169
199
|
# Write to BOTH stores atomically
|
|
170
200
|
desc = f'Memory profile: {name}'
|
|
171
201
|
ensure_profile_in_db(name, desc)
|
|
172
202
|
ensure_profile_in_json(name, desc)
|
|
173
203
|
|
|
204
|
+
# A logged-in user who creates a workspace becomes its admin so they
|
|
205
|
+
# can manage it immediately (profile_id == name here). The machine
|
|
206
|
+
# owner needs no membership row (implicit root).
|
|
207
|
+
if _principal.get("kind") == "user":
|
|
208
|
+
rbac = getattr(request.app.state, "rbac", None)
|
|
209
|
+
if rbac is not None:
|
|
210
|
+
try:
|
|
211
|
+
rbac.set_membership(name, _principal["user_id"], "admin",
|
|
212
|
+
added_by=_principal["username"])
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
174
216
|
authorization.complete()
|
|
175
217
|
return {"success": True, "profile": name, "message": f"Profile '{name}' created"}
|
|
176
218
|
|
|
177
219
|
except HTTPException:
|
|
178
220
|
raise
|
|
179
|
-
except Exception
|
|
180
|
-
raise
|
|
221
|
+
except Exception:
|
|
222
|
+
raise _internal_error("Profile create error")
|
|
181
223
|
|
|
182
224
|
|
|
183
225
|
@router.delete("/api/profiles/{name}")
|
|
@@ -204,6 +246,10 @@ async def delete_profile(name: str, request: Request):
|
|
|
204
246
|
source_agent_id="http-profile-delete",
|
|
205
247
|
profile_id=name,
|
|
206
248
|
)
|
|
249
|
+
# RBAC (C3): deleting a tenant is administrative. Check MANAGE on the
|
|
250
|
+
# profile being deleted (not the active one).
|
|
251
|
+
from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
|
|
252
|
+
_rbac_manage(request, profile=name)
|
|
207
253
|
# Move data to default before deleting (bypasses CASCADE)
|
|
208
254
|
conn = get_db_connection()
|
|
209
255
|
cursor = conn.cursor()
|
|
@@ -243,5 +289,5 @@ async def delete_profile(name: str, request: Request):
|
|
|
243
289
|
|
|
244
290
|
except HTTPException:
|
|
245
291
|
raise
|
|
246
|
-
except Exception
|
|
247
|
-
raise
|
|
292
|
+
except Exception:
|
|
293
|
+
raise _internal_error("Profile delete error")
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
_atomic_write,
|
|
37
|
+
_read_config,
|
|
38
|
+
_require_admin,
|
|
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
|
+
@router.get("/ratelimit")
|
|
85
|
+
def get_ratelimit() -> JSONResponse:
|
|
86
|
+
return JSONResponse(_effective())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.put("/ratelimit")
|
|
90
|
+
async def put_ratelimit(request: Request) -> JSONResponse:
|
|
91
|
+
_require_admin(request)
|
|
92
|
+
try:
|
|
93
|
+
raw = await request.json()
|
|
94
|
+
except Exception:
|
|
95
|
+
raw = {}
|
|
96
|
+
try:
|
|
97
|
+
payload = RateLimitUpdate(**(raw or {}))
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
return JSONResponse(status_code=422, content={"error": str(exc)})
|
|
100
|
+
|
|
101
|
+
if payload.write is None and payload.read is None and payload.window is None:
|
|
102
|
+
return JSONResponse(
|
|
103
|
+
status_code=422,
|
|
104
|
+
content={"error": "Provide at least one of write, read, window."},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Runtime apply (reconfigures every live limiter — no restart).
|
|
108
|
+
effective = set_limits(
|
|
109
|
+
write=payload.write, read=payload.read, window=payload.window,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Persist so the override survives restart.
|
|
113
|
+
try:
|
|
114
|
+
cfg = _read_config()
|
|
115
|
+
cfg[_CONFIG_KEY] = {
|
|
116
|
+
"write": effective["write"],
|
|
117
|
+
"read": effective["read"],
|
|
118
|
+
"window": effective["window"],
|
|
119
|
+
}
|
|
120
|
+
_atomic_write(cfg)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.warning("rate-limit persist failed (applied at runtime): %s", exc)
|
|
123
|
+
|
|
124
|
+
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}
|
|
@@ -19,6 +19,12 @@ logger = logging.getLogger("superlocalmemory.routes.stats")
|
|
|
19
19
|
router = APIRouter()
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
23
|
+
"""SEC-H-02: log full traceback server-side; return a generic message to the client."""
|
|
24
|
+
logger.exception("stats route error")
|
|
25
|
+
return HTTPException(status_code=500, detail=detail)
|
|
26
|
+
|
|
27
|
+
|
|
22
28
|
@router.get("/api/stats")
|
|
23
29
|
async def get_stats():
|
|
24
30
|
"""Get comprehensive system statistics."""
|
|
@@ -237,8 +243,8 @@ async def get_stats():
|
|
|
237
243
|
},
|
|
238
244
|
}
|
|
239
245
|
|
|
240
|
-
except Exception
|
|
241
|
-
raise
|
|
246
|
+
except Exception:
|
|
247
|
+
raise _internal_error("Stats error")
|
|
242
248
|
|
|
243
249
|
|
|
244
250
|
@router.get("/api/timeline")
|
|
@@ -305,8 +311,8 @@ async def get_timeline(
|
|
|
305
311
|
"parameters": {"days": days, "group_by": group_by},
|
|
306
312
|
}
|
|
307
313
|
|
|
308
|
-
except Exception
|
|
309
|
-
raise
|
|
314
|
+
except Exception:
|
|
315
|
+
raise _internal_error("Timeline error")
|
|
310
316
|
|
|
311
317
|
|
|
312
318
|
@router.get("/api/patterns")
|
|
@@ -436,5 +442,6 @@ async def get_patterns():
|
|
|
436
442
|
"pattern_types": list(grouped.keys()), "confidence_stats": confidence_stats,
|
|
437
443
|
}
|
|
438
444
|
|
|
439
|
-
except Exception
|
|
440
|
-
|
|
445
|
+
except Exception:
|
|
446
|
+
logger.exception("patterns route error")
|
|
447
|
+
return {"patterns": {}, "total_patterns": 0, "error": "Internal server error"}
|