engraphis 0.5.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. {engraphis-0.5.2/engraphis.egg-info → engraphis-0.7.0}/PKG-INFO +1 -1
  2. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/autosync.py +24 -83
  3. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/dashboard_app.py +2 -1
  4. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/auth.py +85 -0
  5. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/webhooks.py +31 -0
  6. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/v2_api.py +23 -6
  7. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/v2_team.py +47 -0
  8. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/service.py +187 -0
  9. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/index.html +30 -12
  10. {engraphis-0.5.2 → engraphis-0.7.0/engraphis.egg-info}/PKG-INFO +1 -1
  11. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/SOURCES.txt +1 -0
  12. {engraphis-0.5.2 → engraphis-0.7.0}/pyproject.toml +1 -1
  13. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_autosync.py +17 -0
  14. engraphis-0.7.0/tests/test_password_reset.py +260 -0
  15. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_workspace_ops.py +89 -6
  16. {engraphis-0.5.2 → engraphis-0.7.0}/LICENSE +0 -0
  17. {engraphis-0.5.2 → engraphis-0.7.0}/NOTICE +0 -0
  18. {engraphis-0.5.2 → engraphis-0.7.0}/README.md +0 -0
  19. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/__init__.py +0 -0
  20. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/analytics.py +0 -0
  21. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/app.py +0 -0
  22. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/automation.py +0 -0
  23. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/__init__.py +0 -0
  24. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/codegraph.py +0 -0
  25. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_api.py +0 -0
  26. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_deterministic.py +0 -0
  27. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_st.py +0 -0
  28. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/encrypted_db.py +0 -0
  29. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/extractor.py +0 -0
  30. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/graph_extractor.py +0 -0
  31. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/reranker.py +0 -0
  32. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/sync_folder.py +0 -0
  33. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/sync_relay.py +0 -0
  34. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/vector_numpy.py +0 -0
  35. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/vector_sqlitevec.py +0 -0
  36. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/billing.py +0 -0
  37. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/cloud_license.py +0 -0
  38. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/config.py +0 -0
  39. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/__init__.py +0 -0
  40. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/consolidate.py +0 -0
  41. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/engine.py +0 -0
  42. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/graphrank.py +0 -0
  43. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/grounded.py +0 -0
  44. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/ids.py +0 -0
  45. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/interfaces.py +0 -0
  46. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/recall.py +0 -0
  47. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/resolve.py +0 -0
  48. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/schema.py +0 -0
  49. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/scoring.py +0 -0
  50. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/store.py +0 -0
  51. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/sync.py +0 -0
  52. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/textutil.py +0 -0
  53. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/__init__.py +0 -0
  54. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/embedder.py +0 -0
  55. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/ingest.py +0 -0
  56. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/intelligence.py +0 -0
  57. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/recall.py +0 -0
  58. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/reweight.py +0 -0
  59. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/thoughts.py +0 -0
  60. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/graphdata.py +0 -0
  61. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/__init__.py +0 -0
  62. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/app.py +0 -0
  63. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/cloud_mount.py +0 -0
  64. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/license_cloud.py +0 -0
  65. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/license_registry.py +0 -0
  66. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/sync_relay.py +0 -0
  67. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/licensing.py +0 -0
  68. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/llm/__init__.py +0 -0
  69. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/llm/client.py +0 -0
  70. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/logging_setup.py +0 -0
  71. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/mcp_server.py +0 -0
  72. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/models.py +0 -0
  73. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/redirector.py +0 -0
  74. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/__init__.py +0 -0
  75. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/memory.py +0 -0
  76. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/vault.py +0 -0
  77. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/engraphis-icon.png +0 -0
  78. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/engraphis.ico +0 -0
  79. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/favicon-32.png +0 -0
  80. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/__init__.py +0 -0
  81. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/graph.py +0 -0
  82. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/ledger.py +0 -0
  83. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/vaults.py +0 -0
  84. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/vectors.py +0 -0
  85. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/dependency_links.txt +0 -0
  86. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/entry_points.txt +0 -0
  87. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/requires.txt +0 -0
  88. {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/top_level.txt +0 -0
  89. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/__init__.py +0 -0
  90. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/auto_maintain.py +0 -0
  91. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/backfill_graph.py +0 -0
  92. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/cli.py +0 -0
  93. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/consolidate.py +0 -0
  94. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/init.py +0 -0
  95. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/inspector.py +0 -0
  96. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/install_shortcuts.py +0 -0
  97. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/license_admin.py +0 -0
  98. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/mcp_server_http.py +0 -0
  99. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/migrate_to_v2.py +0 -0
  100. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/sdk_compat.py +0 -0
  101. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/seed_from_obsidian.py +0 -0
  102. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/smoke_cloud.py +0 -0
  103. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/start_dashboard.py +0 -0
  104. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/start_server.py +0 -0
  105. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/sync.py +0 -0
  106. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/test_routes.py +0 -0
  107. {engraphis-0.5.2 → engraphis-0.7.0}/scripts/update.py +0 -0
  108. {engraphis-0.5.2 → engraphis-0.7.0}/setup.cfg +0 -0
  109. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_analytics.py +0 -0
  110. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_app_auth.py +0 -0
  111. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_backends_factories.py +0 -0
  112. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_billing.py +0 -0
  113. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_cloud_endpoints_mounted.py +0 -0
  114. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_cloud_license.py +0 -0
  115. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_codegraph.py +0 -0
  116. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_config.py +0 -0
  117. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_consolidate.py +0 -0
  118. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_core_ids.py +0 -0
  119. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_core_store.py +0 -0
  120. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_dashboard_v2.py +0 -0
  121. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_encrypted_store.py +0 -0
  122. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_engine.py +0 -0
  123. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_eval_external.py +0 -0
  124. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_eval_harness.py +0 -0
  125. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_extractor.py +0 -0
  126. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graph_extractor.py +0 -0
  127. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graphdata.py +0 -0
  128. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graphrank.py +0 -0
  129. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_grounded.py +0 -0
  130. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_ingest_entities.py +0 -0
  131. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_init.py +0 -0
  132. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_inspector.py +0 -0
  133. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_inspector_pro.py +0 -0
  134. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_licensing.py +0 -0
  135. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_logging_setup.py +0 -0
  136. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_mcp_server.py +0 -0
  137. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_merge.py +0 -0
  138. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_migration.py +0 -0
  139. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_provenance_flags.py +0 -0
  140. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_ready.py +0 -0
  141. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_recall.py +0 -0
  142. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_resolve.py +0 -0
  143. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_scoring.py +0 -0
  144. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service.py +0 -0
  145. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service_graph.py +0 -0
  146. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service_isolation.py +0 -0
  147. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_session_idempotent.py +0 -0
  148. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync.py +0 -0
  149. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_cli.py +0 -0
  150. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_dashboard.py +0 -0
  151. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_relay.py +0 -0
  152. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_team_audit.py +0 -0
  153. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_v1_hardening.py +0 -0
  154. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_v1_licensing.py +0 -0
  155. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_vector_numpy.py +0 -0
  156. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_webhook_e2e.py +0 -0
  157. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_webhooks.py +0 -0
  158. {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_workspace_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: engraphis
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Local-first AI memory engine for agents — Ebbinghaus decay, interaction-aware recall, bi-temporal facts, hybrid retrieval, and an MCP server. You bring the LLM.
5
5
  Author: The Engraphis Authors
6
6
  License: Apache-2.0
@@ -2,62 +2,44 @@
2
2
 
3
3
  Cloud sync (``core/sync.py`` + the relay transport) already replicates the memory store
4
4
  across a user's devices and, on Team, across the whole group. By default a human presses
5
- "Sync now" in the dashboard (or crons ``scripts/sync.py``). This module adds the two
6
- hands-off options the Team tier asks for both **opt-in, both off by default**, and both
7
- toggleable by any team member (not just admins), because keeping the shared store converged
8
- is everyone's job:
5
+ "Sync now" in the dashboard (or crons ``scripts/sync.py``). This module adds the hands-off
6
+ option the Team tier asks for: a *persisted auto-sync policy* plus a background runner, so
7
+ the store and every teammate's view of it stays converged **on a cadence** (minimum
8
+ 5 minutes) without anyone clicking.
9
9
 
10
- * **cadence** (``enabled`` + ``cadence_minutes``) sync the whole team's store on a timer.
11
- * **on_update** (``on_update``) sync shortly after *this* member changes a memory, so
12
- their edit reaches the rest of the team without waiting for the next tick.
13
-
14
- A background loop (``dashboard_app._maybe_start_autosync``) drives both: it wakes on the
15
- cadence clock and on a write signal (:func:`mark_dirty`, raised by a small dashboard
16
- middleware after any successful memory-write request), debounces a burst of edits, then
17
- runs one convergent sync.
10
+ Cadence-only on purpose. A fixed interval caps relay traffic to a known ceiling (at most a
11
+ handful of syncs an hour), whereas syncing on every edit would make load — and, on a
12
+ metered relay host, cost scale with how much the team types. Each sync is a full-state
13
+ bundle per workspace (export + upload + pull peers), so predictable beats chatty.
18
14
 
19
15
  House style (AGENTS.md §3): pure policy helpers + thin IO, exactly like
20
16
  :mod:`engraphis.automation`. There is deliberately **no gate in here** — the sync-feature
21
- gate lives at the entry points (the ``/api/sync/*`` routes) and, non-bypassably, at the
22
- relay itself, which verifies the license server-side. ``run_once`` re-uses the same audited
17
+ gate lives at the entry points (the ``/api/sync/*`` routes; changing the policy is
18
+ admin-only in team mode, see ``inspector/auth.min_role``) and, non-bypassably, at the relay
19
+ itself, which verifies the license server-side. ``run_once`` re-uses the same audited
23
20
  per-workspace sync the dashboard button already calls: it adds a *trigger*, never a new
24
21
  trust boundary. A pulled bundle is still untrusted and still validated/clamped by
25
- ``SyncEngine.apply_bundle`` whether a human, the timer, or an edit initiated the pull
26
- (SECURITY.md, docs/SYNC.md).
22
+ ``SyncEngine.apply_bundle`` whether a human or the timer initiated the pull (SECURITY.md,
23
+ docs/SYNC.md).
27
24
  """
28
25
  from __future__ import annotations
29
26
 
30
27
  import json
31
28
  import os
32
- import threading
33
29
  import time
34
30
  from pathlib import Path
35
31
  from typing import Any, Optional
36
32
 
37
- #: Everything off until the operator opts in; a 15-minute cadence balances freshness
38
- #: against relay chatter, floored at ``MIN_CADENCE_MINUTES`` so a fat-fingered 0 can't
39
- #: hammer the relay.
33
+ #: Off until the operator opts in; a 15-minute cadence balances freshness against relay
34
+ #: chatter, floored at ``MIN_CADENCE_MINUTES`` so a fat-fingered 0 can't hammer the relay.
40
35
  DEFAULT_POLICY: dict = {
41
- "enabled": False, # cadence (timer) sync
36
+ "enabled": False,
42
37
  "cadence_minutes": 15,
43
- "on_update": False, # sync shortly after a local memory write
44
38
  }
45
39
  MIN_CADENCE_MINUTES = 5
46
- #: Settle window (seconds) after the first write in a burst before we sync, so a flurry of
47
- #: edits collapses into one push instead of one-per-edit.
48
- UPDATE_DEBOUNCE_SECONDS = 3.0
49
40
 
50
41
  _POLICY_KEYS = set(DEFAULT_POLICY)
51
42
 
52
- # ── write-signal plumbing (in-memory, per dashboard process) ──────────────────────────
53
- #: Set by :func:`mark_dirty` to wake the background loop the moment a write lands.
54
- WAKE = threading.Event()
55
- #: Timestamp of the earliest un-synced write in the current burst (0.0 == nothing pending).
56
- _DIRTY_AT: float = 0.0
57
- #: Cached ``on_update`` flag so the hot write path never touches disk. ``None`` == unknown
58
- #: (lazily loaded from the policy on first use; refreshed by :func:`save_policy`).
59
- _ON_UPDATE: Optional[bool] = None
60
-
61
43
 
62
44
  def policy_path() -> Path:
63
45
  """Where the auto-sync policy is persisted (next to the DB, else ~/.engraphis)."""
@@ -81,14 +63,16 @@ def _read() -> dict:
81
63
 
82
64
 
83
65
  def normalize_policy(raw: dict) -> dict:
84
- """Coerce arbitrary input into a safe, fully-populated policy (never raises)."""
66
+ """Coerce arbitrary input into a safe, fully-populated policy (never raises).
67
+
68
+ ``cadence_minutes`` is floored at :data:`MIN_CADENCE_MINUTES` (5) so neither a slip of
69
+ the finger nor a crafted request can drive the relay faster than once every 5 minutes."""
85
70
  raw = raw if isinstance(raw, dict) else {}
86
71
  p = dict(DEFAULT_POLICY)
87
72
  for k in _POLICY_KEYS:
88
73
  if k in raw:
89
74
  p[k] = raw[k]
90
75
  p["enabled"] = bool(p["enabled"])
91
- p["on_update"] = bool(p["on_update"])
92
76
  try:
93
77
  p["cadence_minutes"] = max(MIN_CADENCE_MINUTES, int(p["cadence_minutes"] or 15))
94
78
  except (TypeError, ValueError):
@@ -124,18 +108,14 @@ def _write(doc: dict) -> None:
124
108
  def save_policy(policy: dict) -> dict:
125
109
  """Persist the auto-sync policy, preserving last_run/last_result telemetry."""
126
110
  existing = _read()
127
- norm = normalize_policy(policy)
128
- _write({"policy": norm,
111
+ _write({"policy": normalize_policy(policy),
129
112
  "last_run": existing.get("last_run"),
130
113
  "last_result": existing.get("last_result")})
131
- global _ON_UPDATE
132
- _ON_UPDATE = norm["on_update"] # keep the hot-path cache honest
133
- WAKE.set() # let the loop re-evaluate cadence/on_update at once
134
114
  return load_policy()
135
115
 
136
116
 
137
117
  def due(policy: dict, *, now: Optional[float] = None) -> bool:
138
- """True when an enabled *cadence* policy is due to run (interval elapsed since run)."""
118
+ """True when an enabled policy is due to run (cadence elapsed since last_run)."""
139
119
  if not policy.get("enabled"):
140
120
  return False
141
121
  now = time.time() if now is None else now
@@ -148,42 +128,6 @@ def due(policy: dict, *, now: Optional[float] = None) -> bool:
148
128
  return True
149
129
 
150
130
 
151
- # ── write signalling (called from the dashboard middleware, cheap + no disk IO) ───────
152
- def _on_update_enabled() -> bool:
153
- """Cached view of the ``on_update`` flag; lazily loads the policy once, then trusts
154
- :func:`save_policy` to refresh it. Keeps :func:`mark_dirty` off the disk."""
155
- global _ON_UPDATE
156
- if _ON_UPDATE is None:
157
- _ON_UPDATE = bool(load_policy().get("on_update"))
158
- return _ON_UPDATE
159
-
160
-
161
- def mark_dirty(*, now: Optional[float] = None) -> None:
162
- """Signal that a local memory write happened. No-op unless ``on_update`` is enabled, so
163
- it is safe (and free) to call from the request path on every write. Records the burst's
164
- start time and wakes the background loop; the loop debounces before syncing."""
165
- if not _on_update_enabled():
166
- return
167
- global _DIRTY_AT
168
- if not _DIRTY_AT: # remember the *first* write of the burst
169
- _DIRTY_AT = time.time() if now is None else now
170
- WAKE.set()
171
-
172
-
173
- def dirty_since() -> float:
174
- """Timestamp of the oldest pending write (0.0 if nothing is pending)."""
175
- return _DIRTY_AT
176
-
177
-
178
- def consume_dirty() -> bool:
179
- """Atomically clear + report the pending-write flag. True == a write was pending."""
180
- global _DIRTY_AT
181
- if _DIRTY_AT:
182
- _DIRTY_AT = 0.0
183
- return True
184
- return False
185
-
186
-
187
131
  def _record(summary: dict, *, now: Optional[float] = None) -> None:
188
132
  """Stamp last_run + a compact last_result onto the persisted policy (best-effort)."""
189
133
  now = time.time() if now is None else now
@@ -193,7 +137,6 @@ def _record(summary: dict, *, now: Optional[float] = None) -> None:
193
137
  "last_run": float(now),
194
138
  "last_result": {
195
139
  "at": float(now),
196
- "trigger": summary.get("trigger", "manual"),
197
140
  "workspaces": int(summary.get("workspaces", 0) or 0),
198
141
  "exported": int(summary.get("exported", 0) or 0),
199
142
  "added": int(summary.get("added", 0) or 0),
@@ -204,12 +147,11 @@ def _record(summary: dict, *, now: Optional[float] = None) -> None:
204
147
  pass
205
148
 
206
149
 
207
- def run_once(service: Any = None, *, trigger: str = "auto", now: Optional[float] = None,
150
+ def run_once(service: Any = None, *, now: Optional[float] = None,
208
151
  record: bool = True) -> dict:
209
152
  """Run one auto-sync pass across every workspace — if this device is licensed for sync
210
153
  and has a key configured. Never raises: a relay/transport failure lands in the
211
- summary's ``errors`` so the background loop keeps ticking. ``trigger`` (``"cadence"`` /
212
- ``"update"`` / ``"auto"``) is recorded for telemetry. Records last_run/last_result
154
+ summary's ``errors`` so the background loop keeps ticking. Records last_run/last_result
213
155
  unless ``record`` is False. Returns the sync summary, or a ``{"skipped": ...}`` note
214
156
  when the plan/key isn't ready (so the loop no-ops cheaply instead of hammering the
215
157
  relay)."""
@@ -221,7 +163,6 @@ def run_once(service: Any = None, *, trigger: str = "auto", now: Optional[float]
221
163
  from engraphis.routes import v2_api
222
164
  svc = service if service is not None else v2_api.service()
223
165
  summary = v2_api._sync_all(svc)
224
- summary["trigger"] = trigger
225
166
  try: # keep the dashboard's "last synced" line honest for auto runs
226
167
  v2_api._SYNC_STATE["last"] = summary
227
168
  except Exception: # noqa: BLE001
@@ -30,7 +30,8 @@ _INDEX = _STATIC / "index.html"
30
30
  # the auth bootstrap endpoints themselves (state/login/setup must work while logged
31
31
  # out) — same shape as engraphis/inspector/app.py's _PUBLIC set.
32
32
  _PUBLIC = {"/", "/api/health", "/api/ready", "/api/auth/state", "/api/auth/login",
33
- "/api/auth/setup", "/api/auth/logout", "/webhooks/polar"}
33
+ "/api/auth/setup", "/api/auth/logout", "/api/auth/forgot", "/api/auth/reset",
34
+ "/webhooks/polar"}
34
35
 
35
36
 
36
37
  def create_app() -> FastAPI:
@@ -32,6 +32,9 @@ SESSION_TTL_SECONDS = 12 * 3600
32
32
  LOCKOUT_FAILS = 5 # failures within LOCKOUT_WINDOW …
33
33
  LOCKOUT_WINDOW = 900 # … lock the account for LOCKOUT_SECONDS
34
34
  LOCKOUT_SECONDS = 60
35
+ RESET_TOKEN_TTL_SECONDS = 1800 # a "forgot password" link is single-use, 30 min
36
+ RESET_REQUEST_MAX = 3 # … and throttled per-email so it can't mail-bomb
37
+ RESET_REQUEST_WINDOW = 3600 # an inbox (independent of the login lockout above)
35
38
 
36
39
  ROLES = ("viewer", "member", "admin")
37
40
  _ROLE_RANK = {r: i for i, r in enumerate(ROLES)}
@@ -55,6 +58,14 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
55
58
  expires_at REAL NOT NULL
56
59
  );
57
60
  CREATE INDEX IF NOT EXISTS idx_sess_user ON auth_sessions(user_id);
61
+ CREATE TABLE IF NOT EXISTS password_resets (
62
+ token_hash TEXT PRIMARY KEY,
63
+ user_id TEXT NOT NULL REFERENCES users(id),
64
+ created_at REAL NOT NULL,
65
+ expires_at REAL NOT NULL,
66
+ used INTEGER NOT NULL DEFAULT 0
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_reset_user ON password_resets(user_id);
58
69
  CREATE TABLE IF NOT EXISTS audit_events (
59
70
  id INTEGER PRIMARY KEY AUTOINCREMENT,
60
71
  ts REAL NOT NULL,
@@ -140,6 +151,7 @@ class AuthStore:
140
151
  self.conn.executescript(_SCHEMA)
141
152
  self.iterations = int(iterations)
142
153
  self._failures: dict = {} # email -> list[fail_ts] (in-memory throttle)
154
+ self._reset_requests: dict = {} # email -> list[req_ts] (forgot-password throttle)
143
155
  self._last_prune: float = 0.0
144
156
 
145
157
  # ── users ──────────────────────────────────────────────────────────────────
@@ -299,6 +311,79 @@ class AuthStore:
299
311
  user["token"] = token
300
312
  return user
301
313
 
314
+ # ── password reset ("forgot password") ─────────────────────────────────────
315
+ def request_password_reset(self, email: str) -> Optional[dict]:
316
+ """Issue a single-use password-reset token for *email*, or return ``None``.
317
+
318
+ Returns ``None`` both when the address doesn't match an (enabled) account
319
+ AND when the per-email throttle is exceeded — callers MUST treat every
320
+ ``None`` identically (always respond as if the email might have been sent)
321
+ so a client can't enumerate registered users, or fingerprint the throttle,
322
+ by watching for a different HTTP response. Raises :class:`AuthError` only
323
+ for a malformed email string (also caller-swallowable for the same reason).
324
+
325
+ Any previously issued, still-unused token for this user is invalidated —
326
+ only the most recent reset link works, so an old email lying around in an
327
+ inbox can't be replayed after a newer one was requested.
328
+ """
329
+ email = self._clean_email(email)
330
+ now = time.time()
331
+ hits = [t for t in self._reset_requests.get(email, []) if now - t < RESET_REQUEST_WINDOW]
332
+ throttled = len(hits) >= RESET_REQUEST_MAX
333
+ hits.append(now)
334
+ self._reset_requests[email] = hits
335
+ if throttled:
336
+ self.record_event("password_reset.throttled", actor_email=email)
337
+ return None
338
+ row = self.conn.execute(
339
+ "SELECT id, email, name, disabled FROM users WHERE email=?", (email,)).fetchone()
340
+ if row is None or row["disabled"]:
341
+ return None
342
+ self.conn.execute("DELETE FROM password_resets WHERE user_id=? AND used=0", (row["id"],))
343
+ token = secrets.token_urlsafe(32)
344
+ self.conn.execute(
345
+ "INSERT INTO password_resets (token_hash, user_id, created_at, expires_at, used) "
346
+ "VALUES (?,?,?,?,0)",
347
+ (_hash_token(token), row["id"], now, now + RESET_TOKEN_TTL_SECONDS))
348
+ self.conn.commit()
349
+ self.record_event("password_reset.requested", actor_id=row["id"], actor_email=email)
350
+ return {"token": token, "email": row["email"], "name": row["name"]}
351
+
352
+ def reset_password(self, token: str, new_password: str) -> dict:
353
+ """Consume a password-reset token and set *new_password*.
354
+
355
+ Raises :class:`AuthError` on an invalid, expired, or already-used token, or
356
+ when the password fails :func:`_validate_password`. On success: every
357
+ existing session for the user is revoked (a token that leaked alongside a
358
+ stolen session, or a session left open on a shared machine, must not
359
+ survive the reset), the login lockout is cleared, and a fresh session is
360
+ created — same shape as :meth:`login`'s return (``user["token"]``) — so the
361
+ caller can sign the user straight back in.
362
+ """
363
+ _validate_password(new_password)
364
+ row = self.conn.execute(
365
+ "SELECT user_id, expires_at, used FROM password_resets WHERE token_hash=?",
366
+ (_hash_token(token),)).fetchone()
367
+ if row is None or row["used"] or row["expires_at"] < time.time():
368
+ raise AuthError("invalid or expired reset link")
369
+ user = self.get_user(row["user_id"])
370
+ if user is None or user["disabled"]:
371
+ raise AuthError("invalid or expired reset link")
372
+ self.conn.execute(
373
+ "UPDATE users SET pw_hash=? WHERE id=?",
374
+ (_hash_password(new_password, iterations=self.iterations), user["id"]))
375
+ self.conn.execute("UPDATE password_resets SET used=1 WHERE token_hash=?",
376
+ (_hash_token(token),))
377
+ self.conn.commit()
378
+ self.revoke_user_sessions(user["id"])
379
+ self._failures.pop(user["email"], None)
380
+ self.record_event("password_reset.completed", actor_id=user["id"],
381
+ actor_email=user["email"])
382
+ new_token = self.create_session(user["id"])
383
+ result = self.get_user(user["id"])
384
+ result["token"] = new_token
385
+ return result
386
+
302
387
  # ── sessions (raw token to the client, hash in the DB) ─────────────────────
303
388
  def create_session(self, user_id: str, *, ttl: int = SESSION_TTL_SECONDS) -> str:
304
389
  token = secrets.token_urlsafe(32)
@@ -409,6 +409,37 @@ and will share it with you separately — this email does not contain it.
409
409
  """
410
410
 
411
411
 
412
+ def _password_reset_email_text(name: str, reset_url: str) -> str:
413
+ greeting = "Hi %s," % name if name else "Hi,"
414
+ return f"""{greeting}
415
+
416
+ Someone requested a password reset for your Engraphis dashboard account. If this
417
+ was you, choose a new password here — this link works once and expires in 30
418
+ minutes:
419
+
420
+ {reset_url}
421
+
422
+ If you didn't request this, you can safely ignore this email: your password has
423
+ not been changed.
424
+
425
+ — The Engraphis team
426
+ """
427
+
428
+
429
+ def send_password_reset_email(to: str, name: str, reset_url: str) -> None:
430
+ """Deliver a one-time password-reset link to *to* (``/api/auth/forgot``).
431
+
432
+ Raises on delivery failure (see :func:`_send_text_email`); the caller
433
+ (``routes.v2_team.forgot``) treats this as best-effort and must never let a
434
+ delivery failure change the HTTP response — the "forgot password" endpoint
435
+ always answers identically regardless of outcome, so a failed send can't be
436
+ used to fingerprint which addresses have accounts.
437
+ """
438
+ subject = "Reset your Engraphis dashboard password"
439
+ text_body = _password_reset_email_text(name, reset_url)
440
+ _send_text_email(to, subject, text_body)
441
+
442
+
412
443
  def send_team_invite_email(to: str, name: str, role: str) -> None:
413
444
  """Notify a newly added dashboard team member (``/api/auth/users``) that
414
445
  their account exists.
@@ -196,6 +196,19 @@ def workspaces_describe(req: _DescribeWsReq):
196
196
  return _run(service().set_workspace_description, req.workspace, req.description)
197
197
 
198
198
 
199
+ class _CopyWsReq(BaseModel):
200
+ workspace: str
201
+ new_name: Optional[str] = None
202
+
203
+
204
+ @router.post("/workspaces/copy")
205
+ def workspaces_copy(req: _CopyWsReq):
206
+ """Duplicate ``workspace`` into a new one (see MemoryService.copy_workspace). When
207
+ ``new_name`` is omitted the name is auto-generated so the dashboard's Copy button
208
+ is a single click."""
209
+ return _run(service().copy_workspace, req.workspace, req.new_name)
210
+
211
+
199
212
  class _DeleteWsReq(BaseModel):
200
213
  workspace: str
201
214
 
@@ -773,23 +786,27 @@ def sync_run():
773
786
 
774
787
 
775
788
  class _AutoSyncReq(BaseModel):
776
- enabled: Optional[bool] = None
789
+ enabled: Optional[bool] = None # cadence (timer) sync
777
790
  cadence_minutes: Optional[int] = None
778
791
 
779
792
 
780
793
  @router.get("/sync/auto")
781
794
  def sync_auto_get():
782
- """The background auto-sync policy for the dashboard toggle (enabled + cadence + last
783
- run). Ungated read it only ever reports a local preference, and defaults to off."""
795
+ """The background auto-sync policy for the dashboard toggle (cadence + last run).
796
+ License-ungated read (it only reports a local preference and defaults to off); in team
797
+ mode the dashboard's role gate still limits it to signed-in users, and only admins can
798
+ POST changes (the toggle renders read-only for members/viewers)."""
784
799
  from engraphis import autosync
785
800
  return autosync.load_policy()
786
801
 
787
802
 
788
803
  @router.post("/sync/auto")
789
804
  def sync_auto_set(req: _AutoSyncReq):
790
- """Enable/disable background auto-sync and set its cadence (minutes). Pro/Team — the
791
- same ``sync`` feature the button needs. The loop itself is licensed-gated too, so a
792
- stale toggle can never reach the relay after a plan lapses."""
805
+ """Enable/disable background cadence auto-sync and set its interval (minutes, floored at
806
+ 5). Pro/Team — the same ``sync`` feature the button needs. In team mode this route is
807
+ **admin-only** (``inspector/auth.min_role``): auto-sync is an account-wide control.
808
+ The loop itself is licensed-gated too, so a stale toggle can never reach the relay
809
+ after a plan lapses."""
793
810
  _paid("sync")
794
811
  from engraphis import autosync
795
812
  cur = autosync.load_policy()
@@ -46,6 +46,15 @@ class LoginReq(BaseModel):
46
46
  password: str = Field(..., min_length=1, max_length=128)
47
47
 
48
48
 
49
+ class ForgotReq(BaseModel):
50
+ email: str = Field(..., min_length=1, max_length=254)
51
+
52
+
53
+ class ResetReq(BaseModel):
54
+ token: str = Field(..., min_length=10, max_length=256)
55
+ password: str = Field(..., min_length=10, max_length=128)
56
+
57
+
49
58
  class NewUserReq(BaseModel):
50
59
  email: str = Field(..., min_length=5, max_length=254)
51
60
  name: str = Field(default="", max_length=120)
@@ -143,6 +152,44 @@ def attach(app: FastAPI, service):
143
152
  _set_cookie(response, u.pop("token"), secure=request.url.scheme == "https")
144
153
  return {"user": u}
145
154
 
155
+ @router.post("/forgot")
156
+ def forgot(body: ForgotReq, request: Request):
157
+ """Request a password-reset link. Always answers ``{"ok": true}`` — the
158
+ response is identical whether or not the email matches an account, the
159
+ account is disabled, or the per-email throttle kicked in, so a client
160
+ can't enumerate registered users by watching for a different reply.
161
+
162
+ If (and only if) a matching, enabled account exists and the throttle
163
+ allows it, a single-use reset link is emailed. Delivery is best-effort:
164
+ a failure is logged server-side and never surfaced to the caller, for the
165
+ same anti-enumeration reason (see send_password_reset_email's docstring).
166
+ """
167
+ try:
168
+ info = store.request_password_reset(body.email)
169
+ except AuthError:
170
+ info = None
171
+ if info:
172
+ base = os.environ.get("ENGRAPHIS_DASHBOARD_URL", "").strip().rstrip("/")
173
+ reset_url = base + "/?reset_token=" + info["token"]
174
+ try:
175
+ from engraphis.inspector.webhooks import send_password_reset_email
176
+ send_password_reset_email(info["email"], info["name"], reset_url)
177
+ except Exception as exc: # noqa: BLE001 — must never change the response
178
+ logger.warning("password reset email to %s failed: %s", info["email"], exc)
179
+ return {"ok": True}
180
+
181
+ @router.post("/reset")
182
+ def reset(body: ResetReq, request: Request, response: Response):
183
+ """Consume a password-reset token issued by ``/forgot`` and sign the user
184
+ back in with a fresh session (old sessions are revoked — see
185
+ AuthStore.reset_password)."""
186
+ try:
187
+ u = store.reset_password(body.token, body.password)
188
+ except AuthError as exc:
189
+ raise HTTPException(status_code=400, detail={"error": str(exc)})
190
+ _set_cookie(response, u.pop("token"), secure=request.url.scheme == "https")
191
+ return {"user": u}
192
+
146
193
  @router.post("/logout")
147
194
  def logout(request: Request, response: Response):
148
195
  tok = request.cookies.get(_COOKIE)