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
@@ -8,22 +8,162 @@ Routes:
8
8
  Cloud: /api/backup/destinations, /api/backup/connect/github, /api/backup/connect/gdrive,
9
9
  /api/backup/disconnect/{id}, /api/backup/sync, /api/backup/export
10
10
  """
11
- import logging
12
11
  import gzip
13
12
  import hashlib
13
+ import hmac
14
+ import html as _html
15
+ import logging
16
+ import secrets
14
17
  import shutil
18
+ import tempfile
19
+ import threading
20
+ import time
15
21
  import urllib.parse
16
22
 
17
23
  from fastapi import APIRouter, HTTPException, Request
18
24
  from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
19
25
  from pydantic import BaseModel, Field
20
- from typing import Optional
26
+ from starlette.background import BackgroundTask
21
27
 
22
- from .helpers import BackupConfigRequest, DB_PATH, MEMORY_DIR
28
+ from .helpers import DB_PATH, MEMORY_DIR, BackupConfigRequest
23
29
 
24
30
  logger = logging.getLogger("superlocalmemory.routes.backup")
25
31
  router = APIRouter()
26
32
 
33
+ _OAUTH_STATE_TTL_SECONDS = 10 * 60
34
+ _OAUTH_STATE_LOCK = threading.Lock()
35
+ _OAUTH_STATES: dict[str, dict[str, object]] = {}
36
+
37
+
38
+ def _require_read(request: Request) -> None:
39
+ """Require READ on the active profile for backup metadata."""
40
+ from superlocalmemory.access.rbac import Permission
41
+ from superlocalmemory.server.rbac_enforce import require_permission
42
+
43
+ require_permission(request, Permission.READ)
44
+
45
+
46
+ def _require_manage(request: Request) -> dict:
47
+ """Require MANAGE for backup configuration, creation, and cloud access."""
48
+ from superlocalmemory.server.rbac_enforce import require_manage
49
+
50
+ return require_manage(request)
51
+
52
+
53
+ def _require_oauth_start(request: Request) -> None:
54
+ """Allow OAuth initiation from the exact dashboard or an authenticated admin."""
55
+ from superlocalmemory.server.origin import origin_is_daemon
56
+
57
+ fetch_site = request.headers.get("sec-fetch-site", "").lower()
58
+ if fetch_site == "cross-site":
59
+ raise HTTPException(
60
+ status_code=403,
61
+ detail="Cross-site OAuth initiation is not allowed.",
62
+ )
63
+ principal = _require_manage(request)
64
+ origin = request.headers.get("origin", "")
65
+ if origin:
66
+ descriptor = getattr(
67
+ getattr(request.app, "state", None),
68
+ "daemon_descriptor",
69
+ None,
70
+ )
71
+ daemon_port = (
72
+ getattr(descriptor, "port", None)
73
+ or request.url.port
74
+ or 8765
75
+ )
76
+ if not origin_is_daemon(origin, port=int(daemon_port)):
77
+ raise HTTPException(
78
+ status_code=403,
79
+ detail="OAuth initiation requires the local dashboard origin.",
80
+ )
81
+ elif fetch_site and fetch_site != "same-origin":
82
+ # Browser top-level navigations commonly omit Origin. Sec-Fetch-Site
83
+ # still distinguishes this daemon's own page (same-origin) from a
84
+ # different localhost port (same-site).
85
+ raise HTTPException(
86
+ status_code=403,
87
+ detail="OAuth initiation requires the local dashboard origin.",
88
+ )
89
+
90
+ host = request.client.host if request.client else ""
91
+ is_loopback = host in {"127.0.0.1", "::1", "localhost", "testclient"}
92
+ if not is_loopback and principal.get("kind") != "user":
93
+ raise HTTPException(
94
+ status_code=403,
95
+ detail=(
96
+ "OAuth connections must start from the local dashboard or an "
97
+ "authenticated admin session."
98
+ ),
99
+ )
100
+
101
+
102
+ def _oauth_context(request: Request) -> str:
103
+ """Fingerprint the local browser/session that initiated an OAuth flow.
104
+
105
+ OAuth callbacks cannot carry the install-token header, so the random state
106
+ is bound to stable browser context available on both the start and callback:
107
+ loopback client address, dashboard user session (when present), and user
108
+ agent. Only the digest is retained in memory.
109
+ """
110
+ client_host = request.client.host if request.client else ""
111
+ session = request.headers.get("X-SLM-User-Session", "")
112
+ if not session:
113
+ session = request.cookies.get("slm_session", "")
114
+ user_agent = request.headers.get("user-agent", "")
115
+ material = "\x00".join((client_host, session, user_agent))
116
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def _issue_oauth_state(provider: str, request: Request) -> str:
120
+ """Create a cryptographically random, expiring, provider-bound state."""
121
+ now = time.monotonic()
122
+ state = secrets.token_urlsafe(32)
123
+ record = {
124
+ "provider": provider,
125
+ "context": _oauth_context(request),
126
+ "expires_at": now + _OAUTH_STATE_TTL_SECONDS,
127
+ }
128
+ with _OAUTH_STATE_LOCK:
129
+ expired = [
130
+ key for key, value in _OAUTH_STATES.items()
131
+ if float(value.get("expires_at", 0)) <= now
132
+ ]
133
+ for key in expired:
134
+ _OAUTH_STATES.pop(key, None)
135
+ _OAUTH_STATES[state] = record
136
+ return state
137
+
138
+
139
+ def _consume_oauth_state(state: str, provider: str, request: Request) -> bool:
140
+ """Atomically consume and validate OAuth state.
141
+
142
+ The pop happens under the lock, so parallel/replayed callbacks cannot both
143
+ exchange the same authorization code. A mismatched provider or browser
144
+ context fails closed and the suspicious state cannot be retried.
145
+ """
146
+ if not state:
147
+ return False
148
+ with _OAUTH_STATE_LOCK:
149
+ record = _OAUTH_STATES.pop(state, None)
150
+ if not record or float(record.get("expires_at", 0)) <= time.monotonic():
151
+ return False
152
+ return (
153
+ hmac.compare_digest(str(record.get("provider", "")), provider)
154
+ and hmac.compare_digest(
155
+ str(record.get("context", "")),
156
+ _oauth_context(request),
157
+ )
158
+ )
159
+
160
+
161
+ def _internal_error(detail: str = "Internal server error") -> HTTPException:
162
+ """SEC-H-02: log full traceback server-side; return a generic message to the client."""
163
+ logger.exception("backup route error")
164
+ return HTTPException(status_code=500, detail=detail)
165
+
166
+
27
167
  # Feature flags
28
168
  BACKUP_AVAILABLE = False
29
169
  CLOUD_AVAILABLE = False
@@ -35,9 +175,11 @@ except ImportError:
35
175
 
36
176
  try:
37
177
  from superlocalmemory.infra.cloud_backup import (
38
- get_destinations, add_destination, remove_destination,
39
- connect_github, connect_google_drive,
40
- sync_all_destinations, update_sync_status,
178
+ connect_github,
179
+ connect_google_drive,
180
+ get_destinations,
181
+ remove_destination,
182
+ sync_all_destinations,
41
183
  )
42
184
  CLOUD_AVAILABLE = True
43
185
  except ImportError:
@@ -67,8 +209,9 @@ class GDriveClientConfig(BaseModel):
67
209
  # ---- Local backup routes (existing) ---------------------------------------
68
210
 
69
211
  @router.get("/api/backup/status")
70
- async def backup_status():
212
+ def backup_status(request: Request):
71
213
  """Get auto-backup system status + cloud destinations."""
214
+ _require_read(request)
72
215
  if not BACKUP_AVAILABLE:
73
216
  return {"status": "not_implemented", "message": "Backup module not available"}
74
217
  try:
@@ -79,13 +222,14 @@ async def backup_status():
79
222
  else:
80
223
  status["cloud_destinations"] = []
81
224
  return status
82
- except Exception as e:
83
- raise HTTPException(status_code=500, detail=f"Backup status error: {str(e)}")
225
+ except Exception:
226
+ raise _internal_error("Backup status error")
84
227
 
85
228
 
86
229
  @router.post("/api/backup/create")
87
- async def backup_create():
230
+ def backup_create(request: Request):
88
231
  """Create a manual backup immediately."""
232
+ _require_manage(request)
89
233
  if not BACKUP_AVAILABLE:
90
234
  return {"success": False, "message": "Backup module not available"}
91
235
  try:
@@ -98,86 +242,102 @@ async def backup_create():
98
242
  "status": manager.get_status(),
99
243
  }
100
244
  return {"success": False, "message": "Backup failed"}
101
- except Exception as e:
102
- raise HTTPException(status_code=500, detail=f"Backup create error: {str(e)}")
245
+ except Exception:
246
+ raise _internal_error("Backup create error")
103
247
 
104
248
 
105
249
  @router.post("/api/backup/configure")
106
- async def backup_configure(request: BackupConfigRequest):
250
+ def backup_configure(request: Request, payload: BackupConfigRequest):
107
251
  """Update auto-backup configuration."""
252
+ _require_manage(request)
108
253
  if not BACKUP_AVAILABLE:
109
254
  return {"success": False, "message": "Backup module not available"}
110
255
  try:
111
256
  manager = _get_backup_manager()
112
257
  result = manager.configure(
113
- interval_hours=request.interval_hours,
114
- max_backups=request.max_backups,
115
- enabled=request.enabled,
258
+ interval_hours=payload.interval_hours,
259
+ max_backups=payload.max_backups,
260
+ enabled=payload.enabled,
116
261
  )
117
262
  return {"success": True, "message": "Backup configuration updated", "status": result}
118
- except Exception as e:
119
- raise HTTPException(status_code=500, detail=f"Backup configure error: {str(e)}")
263
+ except Exception:
264
+ raise _internal_error("Backup configure error")
120
265
 
121
266
 
122
267
  @router.get("/api/backup/list")
123
- async def backup_list():
268
+ def backup_list(request: Request):
124
269
  """List all available backups."""
270
+ _require_read(request)
125
271
  if not BACKUP_AVAILABLE:
126
272
  return {"backups": [], "count": 0, "message": "Backup module not available"}
273
+ # This GET is not covered by the mutation middleware. The local machine
274
+ # owner (loopback) is trusted; a remote caller must present a credential;
275
+ # non-loopback uncredentialed callers fail closed. Using the mutation-actor
276
+ # boundary (not require_write_actor) so the same-origin dashboard — whose
277
+ # fetch wrapper only attaches the install token to mutating requests — can
278
+ # still read its own backup list.
279
+ from superlocalmemory.server.write_identity import require_http_mutation_actor
280
+ require_http_mutation_actor(request, getattr(request.app.state, "daemon_descriptor", None),
281
+ actor_kind="backup-list")
127
282
  try:
128
283
  manager = _get_backup_manager()
129
284
  backups = manager.list_backups()
130
285
  return {"backups": backups, "count": len(backups)}
131
- except Exception as e:
132
- raise HTTPException(status_code=500, detail=f"Backup list error: {str(e)}")
286
+ except Exception:
287
+ raise _internal_error("Backup list error")
133
288
 
134
289
 
135
290
  # ---- Cloud destination routes (v3.4.10) -----------------------------------
136
291
 
137
292
  @router.get("/api/backup/destinations")
138
- async def list_destinations():
293
+ def list_destinations(request: Request):
139
294
  """List all configured cloud backup destinations."""
295
+ _require_read(request)
140
296
  if not CLOUD_AVAILABLE:
141
297
  return {"destinations": [], "cloud_available": False}
142
298
  return {"destinations": get_destinations(DB_PATH), "cloud_available": True}
143
299
 
144
300
 
145
301
  @router.post("/api/backup/connect/github")
146
- async def connect_github_route(request: GitHubConnectRequest):
302
+ def connect_github_route(request: Request, payload: GitHubConnectRequest):
147
303
  """Connect GitHub as a backup destination using PAT."""
304
+ _require_manage(request)
148
305
  if not CLOUD_AVAILABLE:
149
306
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
150
- result = connect_github(request.pat, request.repo_name)
307
+ result = connect_github(payload.pat, payload.repo_name)
151
308
  if "error" in result:
152
309
  raise HTTPException(status_code=400, detail=result["error"])
153
310
  return result
154
311
 
155
312
 
156
313
  @router.post("/api/backup/connect/gdrive/config")
157
- async def configure_gdrive_client(request: GDriveClientConfig):
314
+ def configure_gdrive_client(request: Request, payload: GDriveClientConfig):
158
315
  """Store Google OAuth client credentials (one-time setup)."""
316
+ _require_manage(request)
159
317
  if not CLOUD_AVAILABLE:
160
318
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
161
319
  from superlocalmemory.infra.cloud_backup import _store_credential
162
- _store_credential("gdrive_client_id", request.client_id)
163
- _store_credential("gdrive_client_secret", request.client_secret)
320
+ _store_credential("gdrive_client_id", payload.client_id)
321
+ _store_credential("gdrive_client_secret", payload.client_secret)
164
322
  return {"success": True, "message": "Google OAuth client configured"}
165
323
 
166
324
 
167
325
  @router.post("/api/backup/connect/gdrive")
168
- async def connect_gdrive_route(request: GDriveConnectRequest):
326
+ def connect_gdrive_route(request: Request, payload: GDriveConnectRequest):
169
327
  """Complete Google Drive OAuth2 flow with authorization code."""
328
+ _require_manage(request)
170
329
  if not CLOUD_AVAILABLE:
171
330
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
172
- result = connect_google_drive(request.auth_code, request.redirect_uri)
331
+ result = connect_google_drive(payload.auth_code, payload.redirect_uri)
173
332
  if "error" in result:
174
333
  raise HTTPException(status_code=400, detail=result["error"])
175
334
  return result
176
335
 
177
336
 
178
337
  @router.delete("/api/backup/disconnect/{dest_id}")
179
- async def disconnect_destination(dest_id: str):
338
+ def disconnect_destination(request: Request, dest_id: str):
180
339
  """Remove a cloud backup destination."""
340
+ _require_manage(request)
181
341
  if not CLOUD_AVAILABLE:
182
342
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
183
343
  ok = remove_destination(dest_id, DB_PATH)
@@ -187,16 +347,16 @@ async def disconnect_destination(dest_id: str):
187
347
 
188
348
 
189
349
  @router.post("/api/backup/sync")
190
- async def sync_cloud():
350
+ def sync_cloud(request: Request):
191
351
  """Manually trigger sync to all cloud destinations.
192
352
 
193
353
  Runs the upload in a background thread so it doesn't block the
194
354
  dashboard. Returns immediately with status 'syncing'. The actual
195
355
  upload status is reflected in the destination's last_sync_status.
196
356
  """
197
- import asyncio
198
357
  import threading
199
358
 
359
+ _require_manage(request)
200
360
  if not CLOUD_AVAILABLE:
201
361
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
202
362
  if not BACKUP_AVAILABLE:
@@ -221,15 +381,26 @@ async def sync_cloud():
221
381
  return {
222
382
  "success": True,
223
383
  "backup": filename,
224
- "sync": {"status": "syncing", "message": "Upload started in background. Check destination status for progress."},
384
+ "sync": {
385
+ "status": "syncing",
386
+ "message": (
387
+ "Upload started in background. Check destination status for progress."
388
+ ),
389
+ },
225
390
  }
226
391
 
227
392
 
228
393
  # ---- Export / Download route (v3.4.10) ------------------------------------
229
394
 
230
- @router.get("/api/backup/export")
231
- async def export_backup():
232
- """Create and download a compressed backup archive."""
395
+ @router.post("/api/backup/export")
396
+ def export_backup(request: Request):
397
+ """Create and download a compressed backup archive.
398
+
399
+ Export is a credentialed mutation because it creates a local snapshot.
400
+ The compressed transport file is temporary and is removed after the
401
+ response completes; the normal retention policy owns the snapshot itself.
402
+ """
403
+ _require_manage(request)
233
404
  if not BACKUP_AVAILABLE:
234
405
  raise HTTPException(status_code=501, detail="Backup module not available")
235
406
 
@@ -242,16 +413,28 @@ async def export_backup():
242
413
  if not backup_path.exists():
243
414
  raise HTTPException(status_code=500, detail="Backup file not found")
244
415
 
245
- # Compress for download
246
- gz_path = backup_path.with_suffix(".db.gz")
247
- with open(backup_path, "rb") as f_in:
248
- with gzip.open(gz_path, "wb") as f_out:
249
- shutil.copyfileobj(f_in, f_out)
416
+ # Compress into a one-response transport file. Never retain another full
417
+ # copy beside the bounded snapshot set.
418
+ with tempfile.NamedTemporaryFile(
419
+ prefix="slm-export-",
420
+ suffix=".db.gz",
421
+ dir=backup_path.parent,
422
+ delete=False,
423
+ ) as temp_file:
424
+ gz_path = backup_path.parent / temp_file.name
425
+ try:
426
+ with open(backup_path, "rb") as f_in:
427
+ with gzip.open(gz_path, "wb") as f_out:
428
+ shutil.copyfileobj(f_in, f_out)
429
+ except Exception:
430
+ gz_path.unlink(missing_ok=True)
431
+ raise
250
432
 
251
433
  return FileResponse(
252
434
  path=str(gz_path),
253
435
  media_type="application/gzip",
254
- filename=gz_path.name,
436
+ filename=f"{backup_path.name}.gz",
437
+ background=BackgroundTask(gz_path.unlink, missing_ok=True),
255
438
  )
256
439
 
257
440
 
@@ -308,7 +491,6 @@ p {{ color: #999; margin: 0 0 20px; font-size: 13px; }}
308
491
  # ``str.format`` doesn't escape HTML, so a hostile OAuth callback can
309
492
  # inject ``<script>`` into these pages. These helpers HTML-escape every
310
493
  # interpolated value before emitting the template.
311
- import html as _html
312
494
 
313
495
 
314
496
  def _oauth_error_page(icon: str, error: str) -> str:
@@ -329,8 +511,9 @@ def _oauth_success_page(icon: str, title: str, message: str) -> str:
329
511
  # ---- Google OAuth SSO Flow ------------------------------------------------
330
512
 
331
513
  @router.get("/api/backup/oauth/google/start")
332
- async def google_oauth_start(request: Request):
514
+ def google_oauth_start(request: Request):
333
515
  """Start Google OAuth2 flow — redirects to Google's login page."""
516
+ _require_oauth_start(request)
334
517
  if not CLOUD_AVAILABLE:
335
518
  return HTMLResponse(_oauth_error_page(icon="&#x26A0;", error="Cloud backup module not available"))
336
519
 
@@ -398,7 +581,7 @@ a { color: #00D4AA; }
398
581
  </div>
399
582
 
400
583
  <p style="color:#555;font-size:11px;margin-top:16px;">
401
- Your credentials are stored in your OS keychain (macOS Keychain / Windows Credential Locker) &mdash; never in plaintext.
584
+ Credentials use your OS credential store when available, with a permission-restricted local fallback.
402
585
  Full guide: <a href="https://github.com/qualixar/superlocalmemory/wiki/Cloud-Backup#google-drive-backup" target="_blank">Cloud Backup Wiki</a>
403
586
  </p>
404
587
  </div>
@@ -408,20 +591,29 @@ async function saveAndConnect() {
408
591
  var csec = document.getElementById('csec').value.trim();
409
592
  if (!cid || !csec) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Both fields required</span>'; return; }
410
593
  document.getElementById('saveBtn').disabled = true;
411
- document.getElementById('status').innerHTML = '<span style="color:#999">Saving...</span>';
594
+ var status = document.getElementById('status');
595
+ status.style.color = '#999';
596
+ status.textContent = 'Saving...';
412
597
  try {
598
+ var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
599
+ if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
600
+ var tokenData = await tokenResp.json();
601
+ if (!tokenData.token) throw new Error('Could not authorize this dashboard');
413
602
  var resp = await fetch('/api/backup/connect/gdrive/config', {
414
- method: 'POST', headers: {'Content-Type': 'application/json'},
603
+ method: 'POST', credentials: 'same-origin',
604
+ headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
415
605
  body: JSON.stringify({client_id: cid, client_secret: csec})
416
606
  });
417
607
  if (resp.ok) {
418
608
  window.location.href = '/api/backup/oauth/google/start';
419
609
  } else {
420
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Failed to save</span>';
610
+ status.style.color = '#ff4757';
611
+ status.textContent = 'Failed to save';
421
612
  document.getElementById('saveBtn').disabled = false;
422
613
  }
423
614
  } catch(e) {
424
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Error: ' + e.message + '</span>';
615
+ status.style.color = '#ff4757';
616
+ status.textContent = 'Error: ' + e.message;
425
617
  document.getElementById('saveBtn').disabled = false;
426
618
  }
427
619
  }
@@ -430,22 +622,37 @@ async function saveAndConnect() {
430
622
  # Build the Google OAuth URL
431
623
  base_url = str(request.base_url).rstrip("/")
432
624
  redirect_uri = f"{base_url}/api/backup/oauth/google/callback"
625
+ state = _issue_oauth_state("google", request)
433
626
 
434
627
  params = urllib.parse.urlencode({
435
628
  "client_id": client_id,
436
629
  "redirect_uri": redirect_uri,
437
630
  "response_type": "code",
438
- "scope": "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
631
+ "scope": "openid https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
439
632
  "access_type": "offline",
440
633
  "prompt": "consent",
634
+ "state": state,
441
635
  })
442
636
 
443
637
  return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
444
638
 
445
639
 
446
640
  @router.get("/api/backup/oauth/google/callback")
447
- async def google_oauth_callback(request: Request, code: str = "", error: str = ""):
641
+ def google_oauth_callback(
642
+ request: Request,
643
+ code: str = "",
644
+ error: str = "",
645
+ state: str = "",
646
+ ):
448
647
  """Google OAuth2 callback — exchanges code for tokens."""
648
+ if not _consume_oauth_state(state, "google", request):
649
+ return HTMLResponse(
650
+ _oauth_error_page(
651
+ icon="&#x274C;",
652
+ error="Invalid, expired, or already-used OAuth state. Start the connection again.",
653
+ ),
654
+ status_code=400,
655
+ )
449
656
  if error:
450
657
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=f"Google denied access: {error}"))
451
658
 
@@ -463,7 +670,7 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
463
670
  return HTMLResponse(_oauth_success_page(
464
671
  icon="&#x2601;&#xFE0F;",
465
672
  title="Google Drive Connected!",
466
- message=f"Signed in as {result.get('email', 'unknown')}. Your memories will be backed up automatically."
673
+ message=f"Signed in as {result.get('email', 'unknown')}. The destination is configured; sync status will appear after an upload."
467
674
  ))
468
675
 
469
676
 
@@ -473,8 +680,9 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
473
680
  # otherwise fall back to Device Flow with a nice UI.
474
681
 
475
682
  @router.get("/api/backup/oauth/github/start")
476
- async def github_oauth_start(request: Request):
683
+ def github_oauth_start(request: Request):
477
684
  """Start GitHub OAuth flow."""
685
+ _require_oauth_start(request)
478
686
  if not CLOUD_AVAILABLE:
479
687
  return HTMLResponse(_oauth_error_page(icon="&#x26A0;", error="Cloud backup module not available"))
480
688
 
@@ -487,12 +695,13 @@ async def github_oauth_start(request: Request):
487
695
  # Full OAuth Web Flow — browser redirects to GitHub login
488
696
  base_url = str(request.base_url).rstrip("/")
489
697
  redirect_uri = f"{base_url}/api/backup/oauth/github/callback"
698
+ state = _issue_oauth_state("github", request)
490
699
 
491
700
  params = urllib.parse.urlencode({
492
701
  "client_id": gh_client_id,
493
702
  "redirect_uri": redirect_uri,
494
703
  "scope": "repo",
495
- "state": hashlib.sha256(base_url.encode()).hexdigest()[:16],
704
+ "state": state,
496
705
  })
497
706
  return RedirectResponse(f"https://github.com/login/oauth/authorize?{params}")
498
707
 
@@ -534,38 +743,61 @@ a { color: #00D4AA; }
534
743
 
535
744
  <p class="hint">
536
745
  Need a token? <a href="https://github.com/settings/tokens/new?scopes=repo&description=SLM+Backup" target="_blank">Create one here</a> (select <code>repo</code> scope).
537
- Your token is stored securely in your OS keychain.
746
+ Your token uses the OS credential store when available, with a permission-restricted local fallback.
538
747
  </p>
539
748
  </div>
540
749
  <script>
541
750
  async function doConnect() {
542
751
  var pat = document.getElementById('pat').value.trim();
543
752
  var repo = document.getElementById('repo').value.trim();
544
- if (!pat) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Token required</span>'; return; }
753
+ var status = document.getElementById('status');
754
+ if (!pat) { status.style.color = '#ff4757'; status.textContent = 'Token required'; return; }
545
755
 
546
756
  var btn = document.getElementById('connectBtn');
547
757
  btn.disabled = true; btn.textContent = 'Connecting...';
548
- document.getElementById('status').innerHTML = '<span style="color:#999">Verifying token and creating repo...</span>';
758
+ status.style.color = '#999';
759
+ status.textContent = 'Verifying token and creating repo...';
549
760
 
550
761
  try {
762
+ var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
763
+ if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
764
+ var tokenData = await tokenResp.json();
765
+ if (!tokenData.token) throw new Error('Could not authorize this dashboard');
551
766
  var resp = await fetch('/api/backup/connect/github', {
552
767
  method: 'POST',
553
- headers: {'Content-Type': 'application/json'},
768
+ credentials: 'same-origin',
769
+ headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
554
770
  body: JSON.stringify({pat: pat, repo_name: repo || 'slm-backup'})
555
771
  });
556
772
  var data = await resp.json();
557
773
  if (resp.ok) {
558
- document.body.innerHTML = '<div class="card" style="text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;">' +
559
- '<div style="font-size:48px;margin-bottom:16px;">&#x2705;</div>' +
560
- '<h2 style="color:#00D4AA;margin:0 0 8px;">GitHub Connected!</h2>' +
561
- '<p style="color:#999;margin:0 0 20px;">Repository: ' + (data.repo || repo) + '</p>' +
562
- '<button class="btn" style="background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;" onclick="window.close()">Close Window</button></div>';
774
+ var card = document.createElement('div');
775
+ card.className = 'card';
776
+ card.style.cssText = 'text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;';
777
+ var icon = document.createElement('div');
778
+ icon.style.cssText = 'font-size:48px;margin-bottom:16px;';
779
+ icon.textContent = '\u2705';
780
+ var title = document.createElement('h2');
781
+ title.style.cssText = 'color:#00D4AA;margin:0 0 8px;';
782
+ title.textContent = 'GitHub Connected!';
783
+ var repoText = document.createElement('p');
784
+ repoText.style.cssText = 'color:#999;margin:0 0 20px;';
785
+ repoText.textContent = 'Repository: ' + (data.repo || repo);
786
+ var closeBtn = document.createElement('button');
787
+ closeBtn.className = 'btn';
788
+ closeBtn.style.cssText = 'background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;';
789
+ closeBtn.textContent = 'Close Window';
790
+ closeBtn.addEventListener('click', function () { window.close(); });
791
+ card.append(icon, title, repoText, closeBtn);
792
+ document.body.replaceChildren(card);
563
793
  } else {
564
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">' + (data.detail || 'Connection failed') + '</span>';
794
+ status.style.color = '#ff4757';
795
+ status.textContent = data.detail || 'Connection failed';
565
796
  btn.disabled = false; btn.textContent = 'Connect';
566
797
  }
567
798
  } catch(e) {
568
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Connection failed</span>';
799
+ status.style.color = '#ff4757';
800
+ status.textContent = 'Connection failed';
569
801
  btn.disabled = false; btn.textContent = 'Connect';
570
802
  }
571
803
  }
@@ -573,17 +805,31 @@ async function doConnect() {
573
805
 
574
806
 
575
807
  @router.get("/api/backup/oauth/github/callback")
576
- async def github_oauth_callback(request: Request, code: str = "", error: str = ""):
808
+ def github_oauth_callback(
809
+ request: Request,
810
+ code: str = "",
811
+ error: str = "",
812
+ state: str = "",
813
+ ):
577
814
  """GitHub OAuth callback — exchanges code for access token."""
815
+ if not _consume_oauth_state(state, "github", request):
816
+ return HTMLResponse(
817
+ _oauth_error_page(
818
+ icon="&#x274C;",
819
+ error="Invalid, expired, or already-used OAuth state. Start the connection again.",
820
+ ),
821
+ status_code=400,
822
+ )
578
823
  if error:
579
824
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=f"GitHub denied access: {error}"))
580
825
 
581
826
  if not code:
582
827
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error="No authorization code received"))
583
828
 
584
- from superlocalmemory.infra.cloud_backup import _get_credential, _store_credential
585
829
  import httpx
586
830
 
831
+ from superlocalmemory.infra.cloud_backup import _get_credential
832
+
587
833
  gh_client_id = _get_credential("github_client_id")
588
834
  gh_client_secret = _get_credential("github_client_secret")
589
835
 
@@ -611,8 +857,9 @@ async def github_oauth_callback(request: Request, code: str = "", error: str = "
611
857
  return HTMLResponse(_oauth_success_page(
612
858
  icon="&#x2705;",
613
859
  title="GitHub Connected!",
614
- message=f"Repository: {result.get('repo', 'slm-backup')}. Your memories will be backed up automatically."
860
+ message=f"Repository: {result.get('repo', 'slm-backup')}. The destination is configured; sync status will appear after an upload."
615
861
  ))
616
862
 
617
- except Exception as exc:
618
- return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=str(exc)))
863
+ except Exception:
864
+ logger.exception("GitHub OAuth callback failed")
865
+ return HTMLResponse(_oauth_error_page(icon="&#x274C;", error="GitHub connection failed"))