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.
- {engraphis-0.5.2/engraphis.egg-info → engraphis-0.7.0}/PKG-INFO +1 -1
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/autosync.py +24 -83
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/dashboard_app.py +2 -1
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/auth.py +85 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/webhooks.py +31 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/v2_api.py +23 -6
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/v2_team.py +47 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/service.py +187 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/index.html +30 -12
- {engraphis-0.5.2 → engraphis-0.7.0/engraphis.egg-info}/PKG-INFO +1 -1
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/SOURCES.txt +1 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/pyproject.toml +1 -1
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_autosync.py +17 -0
- engraphis-0.7.0/tests/test_password_reset.py +260 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_workspace_ops.py +89 -6
- {engraphis-0.5.2 → engraphis-0.7.0}/LICENSE +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/NOTICE +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/README.md +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/analytics.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/app.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/automation.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/codegraph.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_api.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_deterministic.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/embedder_st.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/encrypted_db.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/extractor.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/graph_extractor.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/reranker.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/sync_folder.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/sync_relay.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/vector_numpy.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/backends/vector_sqlitevec.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/billing.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/cloud_license.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/config.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/consolidate.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/engine.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/graphrank.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/grounded.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/ids.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/interfaces.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/recall.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/resolve.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/schema.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/scoring.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/store.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/sync.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/core/textutil.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/embedder.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/ingest.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/intelligence.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/recall.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/reweight.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/engines/thoughts.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/graphdata.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/app.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/cloud_mount.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/license_cloud.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/license_registry.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/inspector/sync_relay.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/licensing.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/llm/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/llm/client.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/logging_setup.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/mcp_server.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/models.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/redirector.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/memory.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/routes/vault.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/engraphis-icon.png +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/engraphis.ico +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/static/favicon-32.png +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/graph.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/ledger.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/vaults.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis/stores/vectors.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/dependency_links.txt +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/entry_points.txt +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/requires.txt +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/engraphis.egg-info/top_level.txt +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/__init__.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/auto_maintain.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/backfill_graph.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/cli.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/consolidate.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/init.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/inspector.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/install_shortcuts.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/license_admin.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/mcp_server_http.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/migrate_to_v2.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/sdk_compat.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/seed_from_obsidian.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/smoke_cloud.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/start_dashboard.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/start_server.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/sync.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/test_routes.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/scripts/update.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/setup.cfg +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_analytics.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_app_auth.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_backends_factories.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_billing.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_cloud_endpoints_mounted.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_cloud_license.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_codegraph.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_config.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_consolidate.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_core_ids.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_core_store.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_dashboard_v2.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_encrypted_store.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_engine.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_eval_external.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_eval_harness.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_extractor.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graph_extractor.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graphdata.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_graphrank.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_grounded.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_ingest_entities.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_init.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_inspector.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_inspector_pro.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_licensing.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_logging_setup.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_mcp_server.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_merge.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_migration.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_provenance_flags.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_ready.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_recall.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_resolve.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_scoring.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service_graph.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_service_isolation.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_session_idempotent.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_cli.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_dashboard.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_sync_relay.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_team_audit.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_v1_hardening.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_v1_licensing.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_vector_numpy.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_webhook_e2e.py +0 -0
- {engraphis-0.5.2 → engraphis-0.7.0}/tests/test_webhooks.py +0 -0
- {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.
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
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
|
-
#:
|
|
38
|
-
#:
|
|
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,
|
|
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
|
-
|
|
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
|
|
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, *,
|
|
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.
|
|
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", "/
|
|
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 (
|
|
783
|
-
|
|
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
|
|
791
|
-
same ``sync`` feature the button needs.
|
|
792
|
-
|
|
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)
|