diff --git a/.env.example b/.env.example index b128dc9..74030dc 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,13 @@ ENGRAPHIS_LOOP_INTERVAL=60 ENGRAPHIS_LOOP_TOP_K=20 # Decay half-life in days (Ebbinghaus). Higher = memories persist longer. ENGRAPHIS_DECAY_HALFLIFE_DAYS=7 + +# ── License & Team mode (Pro) ─────────────────────────────────────────────── +# Engraphis core is free forever. A signed license key unlocks Pro features +# (analytics dashboard, compliance export) and Team mode (multi-user Inspector). +# Get a key at https://engraphis.dev/pro, then either set it here or paste it +# in the Inspector's license dialog (persists to ~/.engraphis/license.key). +# ENGRAPHIS_LICENSE_KEY=ENGR1.xxxxx.yyyyy +# Team mode: per-user logins + roles (admin/member/viewer) on the Inspector. +# Requires a Team license; first visit creates the admin account. +# ENGRAPHIS_TEAM_MODE=1 diff --git a/.gitignore b/.gitignore index efbc81f..9e690d5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/ .pytest_cache/ .ruff_cache/ models_cache/ +.secrets/ diff --git a/AGENTS.md b/AGENTS.md index c5770c2..4cca595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,16 @@ python -m eval.external --dataset locomo10.json --format locomo --offline --limi # ── v2 Memory Inspector (product UI over MemoryService; same layer as the MCP server) ── python -m scripts.inspector # http://127.0.0.1:8710 (auth: ENGRAPHIS_API_TOKEN) +# ── Onboarding (writes .env with an absolute DB path; doctor mode verifies install) ── +engraphis-init # or: python -m scripts.init +engraphis-init --check + +# ── Commercial layer (docs/LAUNCH_PLAN.md; gates live ONLY in inspector/app.py) ── +python -m scripts.license_admin keygen # vendor keypair → .secrets/ (gitignored) +python -m scripts.license_admin issue --email a@b.co --plan team --seats 5 --days 365 +ENGRAPHIS_LICENSE_KEY=ENGR1... # or ~/.engraphis/license.key; free tier = no key +ENGRAPHIS_TEAM_MODE=1 # multi-user Inspector (needs a 'team' license) + # ── Sleep-time consolidation (schedulable local job; also an MCP tool) ──────── python -m scripts.consolidate --db engraphis.db --workspace acme --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 6847d71..7880af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,49 @@ All notable changes to Engraphis are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions use SemVer. +## [Unreleased] — commercial layer: license keys, Pro analytics/export, Team mode + +### Added +- **Offline signed license keys** (`engraphis/licensing.py`): Ed25519-signed + `ENGR1..` keys verified pure-stdlib (RFC 8032 implementation, tested against + the RFC's own vectors) — no phone-home, no license server, no new dependency. Vendor CLI: + `python -m scripts.license_admin keygen|issue|verify` (private key lives in gitignored + `.secrets/`). Free tier is the absence of a key, never an error; a bad/expired key degrades + to free with the reason surfaced in the UI. +- **Pro: Analytics dashboard** — `/api/analytics` + an Analytics tab in the Inspector: + weekly growth, retention distribution, decay forecast (what the consolidation sweep will + archive in 7/30 days), resolver action mix, most-connected entities. Data layer is a pure + tested function (`engraphis/analytics.py`); charts are dependency-free inline SVG. +- **Pro: Compliance export** — `/api/export` + an export button in the Audit tab: full + bi-temporal workspace dump (live + superseded memories, sessions, audit trail) as + downloadable JSON (`MemoryService.export_workspace`). +- **Team mode** (`ENGRAPHIS_TEAM_MODE=1` + a Team license): multi-user Inspector with + PBKDF2 logins, hashed session cookies (HttpOnly, SameSite=Strict), first-run admin setup, + seat limits from the signed key, and server-side roles — viewer (read) < member + (+ governance) < admin (+ consolidate/users/license/export). Bearer token still works as a + service account for scripts. Single-user setups are byte-for-byte unchanged. +- **Upgrade UX without nagging**: plan badge + license dialog (paste-to-activate via + `/api/license/activate`), locked-feature teasers rendered only where a locked feature was + explicitly opened, and a guided first-run empty state with a copyable MCP config snippet. +- Tests: 40+ new (RFC 8032 vectors, key tamper/expiry/seats, role matrix, login throttle, + 402 gating, analytics math) — all offline, `importorskip`-guarded like the rest. + +- **Per-user audit attribution**: in team mode, pin/forget/correct audit rows record the + signed-in user's email as the actor (service + engine already supported `actor`; the + Inspector now passes it) — the Team tier's audit trail answers *who*, not just what. +- **`engraphis-init`** — one command from `pip install` to a configured, agent-connected + setup: writes `.env` with an **absolute** DB path (the silent default previously landed in + the package directory — site-packages on pip installs), optional `--token`, prints exact + Claude Code / Cursor / Cline / Zed MCP snippets with the DB path pinned via `env`, and + `--check` is a doctor (install, extras, DB writability, license state). +- Inspector polish: relative timestamps in the audit trail (exact time on hover), `/` + focuses the active tab's search box, Dockerfile documents the Inspector port (8710). + +### Docs +- `docs/LAUNCH_PLAN.md` — monetization architecture, tier table, payments plan (merchant of + record), UI/UX roadmap, launch checklist. `SECURITY.md` §6 documents the team-auth design. +- README: corrected the MCP tool count (17), added `engraphis-init` to the quickstart. + ## [Unreleased] — v1 dashboard drill-down + polish pass ### Fixed diff --git a/Dockerfile b/Dockerfile index 9f94774..f4e937e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ RUN useradd --create-home --uid 10001 engraphis && mkdir -p /data && chown -R en USER engraphis VOLUME ["/data"] EXPOSE 8700 +# Memory Inspector (product UI): run with `docker … engraphis-inspector` or a second service. +EXPOSE 8710 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8700/memory/health').status==200 else 1)" diff --git a/README.md b/README.md index 21e502c..7835373 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,38 @@ pip install -e ".[all]" # everything --- +## Free forever vs. Pro + +The engine — recall, bi-temporal history, governance, code graph, MCP server, single-user +Inspector — is free and Apache-2.0, permanently. A license key (verified **offline**; no +phone-home, in keeping with local-first) unlocks the paid layer: + +| | Free | Pro ($20/mo) | Team ($35/user/mo) | +|---|---|---|---| +| Memory engine + 17 MCP tools | ✓ | ✓ | ✓ | +| Memory Inspector (single-user) | ✓ | ✓ | ✓ | +| Analytics dashboard (growth, retention, decay forecast) | | ✓ | ✓ | +| Compliance export (full bi-temporal JSON dump) | | ✓ | ✓ | +| Multi-user Inspector: logins, roles, seat management | | | ✓ | +| Priority support | | ✓ | ✓ | + +Paste your key into the Inspector's license dialog (the plan badge, top-left) or set +`ENGRAPHIS_LICENSE_KEY`. Get a key at . + +--- + ## Quickstart A — MCP server (the headline) Plug Engraphis into any MCP-capable agent. With Claude Code: ```bash pip install -e ".[mcp]" +engraphis-init # writes .env (DB location) + prints the exact snippets claude mcp add engraphis -- engraphis-mcp ``` +`engraphis-init --check` is the doctor: verifies the install, extras, and DB writability. + For Cursor / Cline / Zed / Windsurf, add to your MCP config: ```json @@ -81,7 +104,7 @@ For Cursor / Cline / Zed / Windsurf, add to your MCP config: } ``` -Your agent now has 15 tools: +Your agent now has 17 tools: | Category | Tool | What it does | |---|---|---| @@ -240,7 +263,7 @@ engraphis/ │ ├── core/ # v2 engine — interfaces, store, recall, scoring, resolve, schema, ids │ ├── backends/ # pluggable embedder / vector index / reranker / codegraph (offline fallbacks) │ ├── service.py # validated MemoryService facade (no MCP dependency) -│ ├── mcp_server.py # MCP server — 15 tools (write/read/governance/code/session) +│ ├── mcp_server.py # MCP server — 17 tools (write/read/governance/code/session) │ ├── config.py # env-driven settings │ ├── app.py # REST server (FastAPI) + dashboard + auth middleware │ ├── routes/ stores/ engines/ llm/ # REST server surface diff --git a/RELEASE_READINESS.md b/RELEASE_READINESS.md index 2d71be8..fa50ab9 100644 --- a/RELEASE_READINESS.md +++ b/RELEASE_READINESS.md @@ -112,7 +112,11 @@ MASTER_PLAN.md; full detail in `CHANGELOG.md`: 3. **Encryption-at-rest** and **built-in rate limiting** (today: rely on disk encryption + a reverse proxy) — required for the regulated/Enterprise ICP. 4. **Per-token tenant authorization** if you sell multi-tenant; today, isolate by instance. -5. **An actual Pro-tier feature to sell.** The v1 dashboard had two real bugs fixed this pass +5. **An actual Pro-tier feature to sell.** *(RESOLVED 2026-07-03 — see + `docs/LAUNCH_PLAN.md` and `CHANGELOG.md`: offline signed license keys now gate three + shipped features — Pro analytics dashboard, compliance export, and multi-user Team mode + on the Inspector — with activation UX in the product. Remaining before charging: rotate + the dev vendor keypair, set the real purchase URL, wire a merchant of record.)* The v1 dashboard had two real bugs fixed this pass (Knowledge Graph fragmentation, stored XSS) and is now correct, but "correct" isn't "worth $20/mo" — it's still a single-user local dashboard with no hosting, login, or team sync. See `docs/GO_TO_MARKET.md` §10 for the specific recommendation (hosted + multi-user version of diff --git a/SECURITY.md b/SECURITY.md index a510963..c06be56 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -137,6 +137,29 @@ repos you intend to index, the same way you'd scope any other local tool. regex indexer on any import or parse failure, rather than failing the write path. Pin versions and run `pip audit` in your environment. +## 6. Team mode & license keys (commercial layer) + +Team mode (`ENGRAPHIS_TEAM_MODE=1` + a `team` license) replaces the single bearer token on +the Inspector's `/api/*` with per-user sessions: + +- **Passwords**: PBKDF2-HMAC-SHA256, 600k iterations, per-user salt, ≥10 chars; verification + is constant-time (`hmac.compare_digest`) and unknown emails still burn one PBKDF2 (no + user-enumeration timing oracle). Login failures share one generic message. +- **Sessions**: 32-byte `secrets` tokens delivered as `HttpOnly; SameSite=Strict; Path=/` + cookies and stored **hashed** (SHA-256) with a 12h TTL — a leaked users DB yields no usable + cookies. Logout, user-disable, and demotion revoke server-side. CSRF posture: SameSite=Strict + + JSON-only bodies + loopback-only CORS; if you expose the Inspector beyond loopback, put + TLS in front (the cookie does not set `Secure` on plain HTTP loopback). +- **Roles** are enforced in the HTTP layer on every request (viewer < member < admin); the UI + only hides what the server already refuses. The last active admin cannot be demoted or + disabled. A valid bearer token acts as an admin service account so automation keeps working. +- **Login throttle**: 5 failures / 15 min → 60 s lockout (in-process, matching the Inspector's + single-process posture). +- **License keys** are Ed25519-signed payloads verified offline against a pinned vendor public + key (`engraphis/licensing.py`); nothing phones home. Keys carry no secrets — they are + entitlements, and the private signing key must never live in the repo (gitignored + `.secrets/`, rotate before selling: `python -m scripts.license_admin keygen`). + ## Known limitations (not yet mitigated) - Rate limiting: an optional in-process per-IP limiter now ships (`ENGRAPHIS_RATE_LIMIT`, `ENGRAPHIS_RATE_WINDOW`), off by default; still front multi-process/distributed deployments diff --git a/docs/GO_TO_MARKET.md b/docs/GO_TO_MARKET.md index be0c6bf..49b79cf 100644 --- a/docs/GO_TO_MARKET.md +++ b/docs/GO_TO_MARKET.md @@ -119,7 +119,7 @@ roadmap item, not a shipped, billable feature. What's actually true today: - The **free core is genuinely strong**: hybrid recall, bi-temporal history (`why`/`timeline`), self-maintaining facts (deterministic conflict resolution), governance (forget/pin/correct, - scope-checked), a code-symbol graph, and a 15-tool MCP server — all covered by 127 passing + scope-checked), a code-symbol graph, and a 17-tool MCP server — all covered by 127 passing tests. That is a real, demonstrable advantage over "just a vector store," and it's free, matching Letta's "self-hosted = all features" positioning from §5. - The **v1 dashboard** (the closest thing to a "Memory Inspector UI" that exists) had two real diff --git a/docs/LAUNCH_PLAN.md b/docs/LAUNCH_PLAN.md new file mode 100644 index 0000000..44f481e --- /dev/null +++ b/docs/LAUNCH_PLAN.md @@ -0,0 +1,126 @@ +# Engraphis — Launch Plan (v1.0 release + first paid tier) + +_Date: 2026-07-03. Companion to `RELEASE_READINESS.md` (state audit) and +`docs/GO_TO_MARKET.md` (pricing research). This document is the execution plan: what ships, +in what order, and how the paid tier works technically and commercially._ + +## 1. Where we are + +The free core is launch-ready (127 tests, offline gate green, 17 MCP tools, hardened write +path). What was missing for revenue — and what this pass adds — is the commercial layer: +there was no license mechanism, no paid feature, and no upgrade path in the product. See §3. + +## 2. Monetization architecture (decided 2026-07-03) + +**Model: open-core with offline signed license keys.** The core stays Apache-2.0 and fully +functional. Pro features ship in this repo but activate only with a valid key. Keys are +Ed25519-signed JSON payloads verified **offline** — no phone-home, no license server, which +keeps the local-first promise intact (a memory engine that phones home would undercut the +entire pitch). + +- Key format: `ENGR1..`; payload carries plan, + features, seats, expiry. Verified against the vendor public key pinned in + `engraphis/licensing.py`. Verification is pure stdlib (RFC 8032 Ed25519), so the + numpy-only core guarantee holds. +- Issue keys with `python -m scripts.license_admin issue --email … --plan team`. The signing + key lives in `.secrets/` (gitignored). **Rotate the committed dev public key before selling + a single license** (`license_admin keygen`), and keep the production private key in a + password manager, never on a dev box. +- Honesty note (Apache-2.0): a determined user can fork out the gate. That is the accepted + trade of the Sidekiq-style model — you sell convenience, updates, and support to the honest + majority. Do not escalate to obfuscation; it poisons trust and never works anyway. + +### Tiers + +| Tier | Price (target) | What's in it | +|------|----------------|--------------| +| Free | $0 forever | Whole engine: MCP server, recall/why/timeline, governance, code graph, single-user Inspector | +| Pro | $20/mo or $200/yr | Analytics dashboard, compliance export (full bi-temporal JSON dump), priority support | +| Team | $35/user/mo | Pro + multi-user Inspector: logins, roles (admin/member/viewer), seat-limited keys | + +$20 anchors against Letta Pro ($20) and mem0 Starter ($19); see GO_TO_MARKET.md §10. Team +pricing is per-seat because the seat count is in the signed key. + +### Payments & fulfillment (not yet built — next step after this pass) + +Use a merchant-of-record (Polar.sh or Lemon Squeezy — handles VAT/sales tax, ~5% fee) rather +than raw Stripe at this stage. Flow: checkout → webhook → `license_admin issue` → key +emailed. Automate with a ~50-line serverless function when volume justifies it; issue keys +manually for the first customers (it's also a customer-discovery channel). Add the purchase +URL in the Inspector's license dialog once live. + +## 3. What ships in this pass (implemented) + +1. **`engraphis/licensing.py`** — key parsing/verification, feature registry, cached + `current_license()`; reads `ENGRAPHIS_LICENSE_KEY` or `~/.engraphis/license.key`. +2. **`scripts/license_admin.py`** — vendor CLI: `keygen`, `issue`, `verify`. +3. **Inspector license UX** — plan badge in the header, license dialog (activate key, see + features), tasteful locked-tab teasers. Free tier is never nagged mid-workflow; upsell + surfaces are opt-in clicks. +4. **Pro: Analytics tab** — memory growth, retention distribution, decay forecast (which + memories fall below the archive threshold in 7/30 days), resolver action mix, top + entities. Server-side in `engraphis/analytics.py` (numpy/stdlib only), inline-SVG charts + client-side (no chart library, consistent with the zero-dependency house style). +5. **Pro: compliance export** — one-click full workspace dump (memories incl. superseded + history, audit trail, sessions) as attachment-download JSON. +6. **Team: multi-user Inspector** — `ENGRAPHIS_TEAM_MODE=1` + a `team` key enables login + (PBKDF2, HttpOnly session cookie), first-run admin setup, roles enforced server-side + (viewer=read, member=+governance, admin=+consolidation/users/export), Team tab for user + management. Without team mode nothing changes for existing single-user setups. +7. **Tests** for all of the above, following the repo's `importorskip` CI-gate convention. + +## 4. UI/UX improvements — beyond this pass + +Ordered by effort-to-impact; the Inspector is already accessible (ARIA tabs, keyboard nav, +dark/light) so this is polish, not rescue: + +1. **First-run experience**: when no workspaces exist, show a guided empty state with the + exact MCP config snippet to paste into Claude Code/Cursor (copy button), instead of a + toast. Biggest funnel fix available — the current first screen is blank. +2. **Onboarding command**: `engraphis init` that writes `.env`, picks a DB path, and prints + the MCP snippet. Closes the "install → configured agent" gap in one step. +3. Global search-as-you-type across tabs (debounced recall), keyboard palette (`/` to focus + search), relative timestamps ("3d ago") with exact time on hover. +4. Graph visualization of entity/link neighborhoods in the detail dialog (SVG, force layout + is overkill — radial layout is fine at this scale). Candidate second Pro feature. +5. Retire the v1 dashboard from the default install (`engraphis-server` keeps serving it for + compat, but docs point at the Inspector only) — one product surface, one story. + +## 5. Feature roadmap (next / later) + +**Next (pre-1.0):** encryption-at-rest for the SQLite file (SQLCipher optional extra → +`encryption` feature flag, regulated-ICP requirement per RELEASE_READINESS.md §"Before you +charge" #3); consolidation policies (saved schedules with per-scope thresholds) as a Pro +feature; publish LoCoMo/LongMemEval numbers (the eval adapter already exists). + +**Later:** SSO/OIDC for Team (gate: first team customer asking); hosted Inspector (gate: +recurring demand — it abandons local-first, so it must be pull, not push); scale backends +(Qdrant/pgvector already behind interfaces); per-token tenant authorization for +multi-tenant hosting. + +## 6. Launch checklist (ordered) + +1. ~~License mechanism + first three paid features~~ (this pass). +2. Rotate vendor keypair; store private key offline. Set the real purchase URL in the + Inspector dialog and `licensing.py`. +3. Set up Polar/Lemon Squeezy product + webhook → key issuance. +4. `git commit` (the tree at HEAD must be the audited one), tag `v0.2.0`, push, verify CI + matrix (3.9/3.11), publish wheel to PyPI, smoke-test `pip install engraphis[all]` and the + Docker image. +5. Run LoCoMo benchmark, publish numbers in README (honest recall@k, per + RELEASE_READINESS.md #1). +6. Trademark search on "Engraphis" (#2 there) before spending on brand. +7. Launch free tier loudly (Show HN, MCP directories, r/LocalLLaMA — the local-first angle + is the hook), sell quietly (license dialog + pricing page). Revisit pricing after ten + real conversations. + +## 7. Risks + +- **Someone forks out the gate** — accepted (see §2); mitigation is velocity and support, + not DRM. +- **Team mode expands the attack surface** — mitigated: PBKDF2-HMAC-SHA256 (600k iters), + hashed session tokens, SameSite=Strict cookies, login backoff, server-side role checks on + every route; see SECURITY.md §6. Still run `/security-review` on any change touching it. +- **Paid features drift into the free core's value story** — the line is: *the engine + remembers for free; seeing, proving, and sharing what it remembers is paid.* Analytics, + export, and team are all on the right side of that line. Keep it that way. diff --git a/engraphis/analytics.py b/engraphis/analytics.py new file mode 100644 index 0000000..a527823 --- /dev/null +++ b/engraphis/analytics.py @@ -0,0 +1,130 @@ +"""Workspace analytics — the Pro dashboard's data layer (docs/LAUNCH_PLAN.md §3.4). + +House style (AGENTS.md §3): the math is a pure, tested function over plain rows +(:func:`analytics_from_rows`); SQL lives only in the thin :func:`compute_analytics` +wrapper. stdlib + core only — no new dependency, no LLM, no network. + +Gating note: the *license check does not live here*. This module computes for whoever +calls it; the Inspector's HTTP layer enforces ``require_feature("analytics")``. Keeping +the gate at the edge means the core stays honestly open (Apache-2.0) and the paid +surface is exactly one, auditable place. +""" +from __future__ import annotations + +import math +import time +from typing import Any, Iterable, Optional + +from engraphis.core.scoring import retention + +#: Retention level treated as "effectively forgotten" — matches the consolidation +#: sweep's ``archive_below`` default so the forecast predicts what the next sweep does. +FORGET_THRESHOLD = 0.05 + +_WEEK = 7 * 86400.0 +_GROWTH_WEEKS = 12 +_HIST_BUCKETS = 5 + + +def _days_until_forgotten(stability: float, last_access: Optional[float], + now: float) -> float: + """Days until Ebbinghaus retention exp(-Δt/S) crosses :data:`FORGET_THRESHOLD`. + + Solving exp(-dt/S) = T gives dt = S·ln(1/T); subtract days already elapsed. + Returns +inf for pinned-style stability outliers only via natural math (S huge). + """ + s = max(stability or 1.0, 1e-3) + horizon_days = s * math.log(1.0 / FORGET_THRESHOLD) + elapsed_days = max((now - (last_access if last_access is not None else now)) / 86400.0, 0.0) + return horizon_days - elapsed_days + + +def analytics_from_rows(mem_rows: Iterable[dict], audit_counts: dict, + entity_rows: Iterable[dict], *, now: Optional[float] = None) -> dict: + """Pure aggregation. ``mem_rows`` need: mtype, stability, last_access, ingested_at, + importance, pinned, valid_to, expired_at. ``audit_counts`` maps action→count. + ``entity_rows`` need: name, etype, n (mention/edge count).""" + now = time.time() if now is None else now + rows = list(mem_rows) + live = [r for r in rows + if r.get("expired_at") is None + and (r.get("valid_to") is None or now < r["valid_to"])] + + # ── growth: weekly ingest counts, oldest→newest, exactly _GROWTH_WEEKS buckets ── + growth = [0] * _GROWTH_WEEKS + for r in rows: + ts = r.get("ingested_at") + if ts is None: + continue + idx = _GROWTH_WEEKS - 1 - int((now - ts) // _WEEK) + if 0 <= idx < _GROWTH_WEEKS: + growth[idx] += 1 + + # ── retention histogram over live memories ── + hist = [0] * _HIST_BUCKETS + ret_sum = 0.0 + for r in live: + ret = retention(r.get("stability") or 1.0, r.get("last_access"), now) + ret_sum += ret + hist[min(int(ret * _HIST_BUCKETS), _HIST_BUCKETS - 1)] += 1 + + # ── decay forecast (pinned memories are exempt from decay archival) ── + at_risk_7 = at_risk_30 = 0 + for r in live: + if r.get("pinned"): + continue + days = _days_until_forgotten(r.get("stability") or 1.0, r.get("last_access"), now) + if days <= 7: + at_risk_7 += 1 + if days <= 30: + at_risk_30 += 1 + + by_type: dict = {} + for r in live: + by_type[r.get("mtype") or "?"] = by_type.get(r.get("mtype") or "?", 0) + 1 + + total = len(rows) + n_live = len(live) + return { + "generated_at": now, + "totals": { + "live": n_live, + "all_rows": total, + "superseded": total - n_live, + "pinned": sum(1 for r in live if r.get("pinned")), + "avg_retention": round(ret_sum / n_live, 4) if n_live else 0.0, + }, + "growth_weekly": growth, + "retention_histogram": { + "buckets": ["0–20%", "20–40%", "40–60%", "60–80%", "80–100%"], + "counts": hist, + }, + "decay_forecast": { + "threshold": FORGET_THRESHOLD, + "at_risk_7d": at_risk_7, + "at_risk_30d": at_risk_30, + }, + "by_type": by_type, + "resolver_mix": {k: int(v) for k, v in sorted(audit_counts.items())}, + "top_entities": [ + {"name": e["name"], "etype": e.get("etype") or "", "n": int(e.get("n") or 0)} + for e in entity_rows + ], + } + + +def compute_analytics(store: Any, workspace_id: str, *, now: Optional[float] = None) -> dict: + """SQL wrapper: fetch rows for one workspace, delegate to the pure function.""" + conn = store.conn + mem_rows = [dict(r) for r in conn.execute( + "SELECT mtype, stability, last_access, ingested_at, importance, pinned, " + "valid_to, expired_at FROM memories WHERE workspace_id=?", (workspace_id,))] + audit_counts = {r["action"]: r["n"] for r in conn.execute( + "SELECT a.action, COUNT(*) AS n FROM audit a JOIN memories m ON m.id = a.target " + "WHERE m.workspace_id=? GROUP BY a.action", (workspace_id,))} + entity_rows = [dict(r) for r in conn.execute( + "SELECT e.name, e.etype, COUNT(ed.id) AS n FROM entities e " + "LEFT JOIN edges ed ON (ed.src = e.id OR ed.dst = e.id) " + "WHERE e.workspace_id=? GROUP BY e.id, e.name, e.etype " + "ORDER BY n DESC, e.created_at DESC LIMIT 8", (workspace_id,))] + return analytics_from_rows(mem_rows, audit_counts, entity_rows, now=now) diff --git a/engraphis/config.py b/engraphis/config.py index ed5112e..9fc1df0 100644 --- a/engraphis/config.py +++ b/engraphis/config.py @@ -52,6 +52,13 @@ class Settings: allowed_workspaces: list = field( default_factory=lambda: _parse_csv(_env("ENGRAPHIS_WORKSPACES", "")) ) + # Team mode (Pro): multi-user Inspector logins/roles. Only takes effect with a + # license carrying the 'team' feature; without one the Inspector reports + # upgrade-required instead of enabling auth (engraphis/inspector/app.py). + team_mode: bool = field( + default_factory=lambda: _env("ENGRAPHIS_TEAM_MODE", "").lower() + in ("1", "true", "yes", "on") + ) db_path: str = field( default_factory=lambda: _env( diff --git a/engraphis/inspector/app.py b/engraphis/inspector/app.py index d69864d..6e8d277 100644 --- a/engraphis/inspector/app.py +++ b/engraphis/inspector/app.py @@ -7,10 +7,19 @@ loopback-only by default. Responses are JSON; the single HTML page renders everything client-side with ``textContent`` (no innerHTML on stored content — the stored-XSS lesson from the v1 dashboard, applied from day one). + +Commercial layer (docs/LAUNCH_PLAN.md §3): this file is also the *only* place the +Pro gates live — ``/api/analytics`` and ``/api/export`` call +``licensing.require_feature`` (→ HTTP 402 with an upgrade hint), and **team mode** +(``ENGRAPHIS_TEAM_MODE=1`` + a ``team`` license) switches ``/api/*`` from the +optional bearer token to real per-user sessions with server-side roles: +viewer (read) < member (+ governance) < admin (+ consolidate/users/license/export). +Free single-user behaviour is byte-for-byte unchanged when team mode is off. """ from __future__ import annotations import hmac +import time from pathlib import Path from typing import Optional @@ -19,11 +28,32 @@ from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel, Field +from engraphis import licensing +from engraphis.analytics import compute_analytics from engraphis.config import settings +from engraphis.inspector.auth import SESSION_TTL_SECONDS, AuthError, AuthStore, role_at_least +from engraphis.licensing import LicenseError from engraphis.service import MemoryService, ValidationError _INDEX = Path(__file__).parent / "index.html" +COOKIE_NAME = "engraphis_session" + +# Reachable without any auth in every mode: the page shell, liveness, and the auth +# bootstrap endpoints themselves (state/login/setup must work while logged out). +_PUBLIC = {"/", "/api/health", "/api/auth/state", "/api/auth/login", "/api/auth/setup"} + + +def _min_role(method: str, path: str) -> str: + """Least role allowed to touch ``path`` in team mode. Server-side is the source of + truth — the UI merely hides what this table already refuses.""" + if path.startswith("/api/auth/users") or path in ( + "/api/license/activate", "/api/export", "/api/consolidate"): + return "admin" + if method == "POST": # pin / forget / correct — audited governance + return "member" + return "viewer" + class _CorrectBody(BaseModel): memory_id: str = Field(min_length=1, max_length=200) @@ -49,9 +79,43 @@ class _ConsolidateBody(BaseModel): archive_below: float = Field(default=0.05, ge=0.0, le=0.5) -def create_app(service: Optional[MemoryService] = None) -> FastAPI: +class _LoginBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=1, max_length=1_000) + + +class _SetupBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + name: str = Field(default="", max_length=120) + password: str = Field(min_length=1, max_length=1_000) + + +class _UserCreateBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + name: str = Field(default="", max_length=120) + password: str = Field(min_length=1, max_length=1_000) + role: str = Field(default="member", max_length=20) + + +class _UserUpdateBody(BaseModel): + user_id: str = Field(min_length=1, max_length=64) + role: Optional[str] = Field(default=None, max_length=20) + disabled: Optional[bool] = None + + +class _ActivateBody(BaseModel): + key: str = Field(min_length=1, max_length=10_000) + + +def _users_db_path(db_path: str) -> str: + return ":memory:" if db_path == ":memory:" else db_path + ".users.db" + + +def create_app(service: Optional[MemoryService] = None, + auth_store: Optional[AuthStore] = None) -> FastAPI: app = FastAPI(title="Engraphis Memory Inspector", docs_url=None, redoc_url=None) app.state.service = service + app.state.auth_store = auth_store app.add_middleware( CORSMiddleware, @@ -59,6 +123,7 @@ def create_app(service: Optional[MemoryService] = None) -> FastAPI: "http://localhost:8710"], allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"], + allow_credentials=True, ) def svc() -> MemoryService: @@ -71,24 +136,155 @@ def svc() -> MemoryService: ) return app.state.service - @app.middleware("http") - async def _bearer_auth(request: Request, call_next): + def auth() -> AuthStore: + if app.state.auth_store is None: + app.state.auth_store = AuthStore(_users_db_path(settings.db_path)) + return app.state.auth_store + + def team_active() -> bool: + return bool(settings.team_mode) and licensing.has_feature("team") + + def _bearer_ok(request: Request) -> bool: token = settings.api_token - if token and request.url.path.startswith("/api/"): - supplied = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip() - if not hmac.compare_digest(supplied, token): - return JSONResponse({"error": "unauthorized"}, status_code=401) + if not token: + return False + supplied = (request.headers.get("Authorization") or "").removeprefix("Bearer ").strip() + return bool(supplied) and hmac.compare_digest(supplied, token) + + @app.middleware("http") + async def _auth_gate(request: Request, call_next): + path = request.url.path + if not path.startswith("/api/") or path in _PUBLIC: + return await call_next(request) + if team_active(): + user = auth().resolve_session(request.cookies.get(COOKIE_NAME, "")) + if user is None and _bearer_ok(request): + # Service-account escape hatch so existing scripts keep working. + user = {"id": "service-token", "email": "service-token", "role": "admin"} + if user is None: + return JSONResponse({"error": "authentication required", "auth": "team"}, + status_code=401) + need = _min_role(request.method, path) + if not role_at_least(user["role"], need): + return JSONResponse({"error": "requires the %s role" % need}, + status_code=403) + request.state.user = user + return await call_next(request) + # Single-user modes: optional bearer token, exactly as before team mode existed. + if settings.api_token and not _bearer_ok(request): + return JSONResponse({"error": "unauthorized"}, status_code=401) return await call_next(request) @app.exception_handler(ValidationError) async def _validation(request: Request, exc: ValidationError): return JSONResponse({"error": str(exc)}, status_code=400) + @app.exception_handler(LicenseError) + async def _license(request: Request, exc: LicenseError): + return JSONResponse({"error": str(exc), "upgrade": True, + "purchase_url": licensing.PURCHASE_URL}, status_code=402) + + @app.exception_handler(AuthError) + async def _autherr(request: Request, exc: AuthError): + return JSONResponse({"error": str(exc)}, status_code=400) + + def _actor(request: Request) -> str: + """Audit attribution: the signed-in user's email in team mode, else a stable + surface tag. This is what makes the Team tier's audit trail answer *who*.""" + user = getattr(request.state, "user", None) + return (user or {}).get("email") or "inspector" + # ── page ──────────────────────────────────────────────────────────────── @app.get("/", include_in_schema=False) async def index(): return FileResponse(_INDEX, media_type="text/html") + # ── auth & licensing ──────────────────────────────────────────────────── + @app.get("/api/auth/state") + async def auth_state(request: Request): + team = team_active() + mode = "team" if team else ("token" if settings.api_token else "open") + user = None + if team: + user = auth().resolve_session(request.cookies.get(COOKIE_NAME, "")) + if user: + user = {"email": user["email"], "name": user["name"], + "role": user["role"]} + lic = licensing.current_license() + return { + "mode": mode, + "setup_required": bool(team and auth().count_users() == 0), + "user": user, + "license": lic.to_public_dict(), + "license_error": licensing.license_error(), + # env asked for team mode but the license lacks it → UI shows the unlock path + "team_locked": bool(settings.team_mode) and not licensing.has_feature("team"), + } + + def _login_response(user: dict) -> JSONResponse: + resp = JSONResponse({"user": {"email": user["email"], "name": user["name"], + "role": user["role"]}}) + resp.set_cookie(COOKIE_NAME, user["token"], max_age=SESSION_TTL_SECONDS, + httponly=True, samesite="strict", path="/") + return resp + + @app.post("/api/auth/setup") + async def auth_setup(body: _SetupBody): + if not team_active(): + raise LicenseError("team mode is not active on this instance") + if auth().count_users() > 0: + return JSONResponse({"error": "setup already completed"}, status_code=409) + auth().create_user(body.email, body.name, body.password, "admin") + user = auth().login(body.email, body.password) + return _login_response(user) + + @app.post("/api/auth/login") + async def auth_login(body: _LoginBody): + if not team_active(): + return JSONResponse({"error": "team mode is not active"}, status_code=400) + try: + user = auth().login(body.email, body.password) + except AuthError as exc: + status = 429 if "too many" in str(exc) else 401 + return JSONResponse({"error": str(exc)}, status_code=status) + return _login_response(user) + + @app.post("/api/auth/logout") + async def auth_logout(request: Request): + token = request.cookies.get(COOKIE_NAME, "") + if token: + auth().revoke_session(token) + resp = JSONResponse({"ok": True}) + resp.delete_cookie(COOKIE_NAME, path="/") + return resp + + @app.get("/api/auth/users") + async def users_list(): + return {"users": auth().list_users(), + "seats": licensing.current_license().seats, + "active": auth().count_active_users()} + + @app.post("/api/auth/users") + async def users_create(body: _UserCreateBody): + user = auth().create_user(body.email, body.name, body.password, body.role, + seat_limit=licensing.current_license().seats) + return {"user": user} + + @app.post("/api/auth/users/update") + async def users_update(body: _UserUpdateBody): + return {"user": auth().update_user(body.user_id, role=body.role, + disabled=body.disabled)} + + @app.get("/api/license") + async def license_state(): + return {"license": licensing.current_license().to_public_dict(), + "license_error": licensing.license_error()} + + @app.post("/api/license/activate") + async def license_activate(body: _ActivateBody): + lic = licensing.activate(body.key) # LicenseError → 402 with the reason + return {"license": lic.to_public_dict(), "activated": True} + # ── read ──────────────────────────────────────────────────────────────── @app.get("/api/health") async def health(): @@ -123,24 +319,40 @@ async def memory(memory_id: str, workspace: str, repo: Optional[str] = None): return svc().inspect(memory_id, workspace=workspace, repo=repo) @app.get("/api/audit") - async def audit(workspace: str, limit: int = 100): + async def audit_log(workspace: str, limit: int = 100): return svc().audit_log(workspace=workspace, limit=limit) + # ── Pro: analytics & compliance export (the 402 upgrade path) ─────────── + @app.get("/api/analytics") + async def analytics(workspace: str): + licensing.require_feature("analytics") + wid, _ = svc()._require_scope(workspace, None) + return compute_analytics(svc().store, wid) + + @app.get("/api/export") + async def export(workspace: str): + licensing.require_feature("export") + data = svc().export_workspace(workspace=workspace) + fname = "engraphis-export-%s-%s.json" % ( + workspace.replace("/", "_"), time.strftime("%Y%m%d")) + return JSONResponse(data, headers={ + "Content-Disposition": 'attachment; filename="%s"' % fname}) + # ── governance (audited; never a hard delete) ─────────────────────────── @app.post("/api/pin") - async def pin(body: _GovernBody): + async def pin(body: _GovernBody, request: Request): return svc().pin(body.memory_id, workspace=body.workspace, repo=body.repo, - pinned=body.pinned) + pinned=body.pinned, actor=_actor(request)) @app.post("/api/forget") - async def forget(body: _GovernBody): + async def forget(body: _GovernBody, request: Request): return svc().forget(body.memory_id, workspace=body.workspace, repo=body.repo, - reason=body.reason) + reason=body.reason, actor=_actor(request)) @app.post("/api/correct") - async def correct(body: _CorrectBody): + async def correct(body: _CorrectBody, request: Request): return svc().correct(body.memory_id, body.new_content, workspace=body.workspace, - repo=body.repo, reason=body.reason) + repo=body.repo, reason=body.reason, actor=_actor(request)) @app.post("/api/consolidate") async def consolidate(body: _ConsolidateBody): diff --git a/engraphis/inspector/auth.py b/engraphis/inspector/auth.py new file mode 100644 index 0000000..808b69d --- /dev/null +++ b/engraphis/inspector/auth.py @@ -0,0 +1,238 @@ +"""Team-mode auth for the Memory Inspector (Pro feature ``team``; LAUNCH_PLAN.md §3.6). + +Design constraints, in order: + +* **Off by default.** Without ``ENGRAPHIS_TEAM_MODE=1`` *and* a ``team`` license this + module is never constructed; the single-user Inspector is untouched. +* **stdlib only** — PBKDF2-HMAC-SHA256 (:data:`PBKDF2_ITERATIONS`), ``secrets`` tokens, + SQLite. Session tokens are stored **hashed** (SHA-256): a leaked users DB does not + yield usable cookies. Passwords: ≥ :data:`MIN_PASSWORD_LEN` chars, per-user salt. +* **Server-side roles.** viewer < member < admin, enforced by the HTTP layer on every + request (`engraphis/inspector/app.py`); the UI only *hides* what the server already + refuses. +* **Single-process posture** (same as the rest of the Inspector): the login throttle is + in-memory, which is exactly right until multi-process hosting exists. + +Users live in a *separate* SQLite file next to the memory DB (``.users.db``) — auth +state is not memory state, and backup/restore of one must not drag the other along. +""" +from __future__ import annotations + +import hashlib +import hmac +import re +import secrets +import sqlite3 +import time +from typing import Optional + +PBKDF2_ITERATIONS = 600_000 +MIN_PASSWORD_LEN = 10 +SESSION_TTL_SECONDS = 12 * 3600 +LOCKOUT_FAILS = 5 # failures within LOCKOUT_WINDOW … +LOCKOUT_WINDOW = 900 # … lock the account for LOCKOUT_SECONDS +LOCKOUT_SECONDS = 60 + +ROLES = ("viewer", "member", "admin") +_ROLE_RANK = {r: i for i, r in enumerate(ROLES)} + +_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,64}$") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT DEFAULT '', + role TEXT NOT NULL CHECK (role IN ('viewer','member','admin')), + pw_hash TEXT NOT NULL, + created_at REAL NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS auth_sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + created_at REAL NOT NULL, + expires_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sess_user ON auth_sessions(user_id); +""" + + +class AuthError(ValueError): + """User-facing auth failure; message is safe to surface.""" + + +def role_at_least(role: str, minimum: str) -> bool: + return _ROLE_RANK.get(role, -1) >= _ROLE_RANK.get(minimum, 99) + + +def _hash_password(password: str, *, iterations: int, salt: Optional[bytes] = None) -> str: + salt = salt if salt is not None else secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return "pbkdf2_sha256$%d$%s$%s" % (iterations, salt.hex(), digest.hex()) + + +def _verify_password(password: str, encoded: str) -> bool: + try: + algo, iters, salt_hex, digest_hex = encoded.split("$") + if algo != "pbkdf2_sha256": + return False + expected = bytes.fromhex(digest_hex) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), + bytes.fromhex(salt_hex), int(iters)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(expected, actual) + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("ascii")).hexdigest() + + +class AuthStore: + """Users + sessions for team mode. One instance per Inspector process.""" + + def __init__(self, db_path: str, *, iterations: int = PBKDF2_ITERATIONS) -> None: + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.executescript(_SCHEMA) + self.iterations = int(iterations) + self._failures: dict = {} # email -> list[fail_ts] (in-memory throttle) + + # ── users ────────────────────────────────────────────────────────────────── + @staticmethod + def _clean_email(email) -> str: + email = (email or "").strip().lower() + if not _EMAIL_RE.match(email): + raise AuthError("invalid email address") + return email + + def create_user(self, email: str, name: str, password: str, role: str, + *, seat_limit: Optional[int] = None) -> dict: + email = self._clean_email(email) + name = (name or "").strip()[:120] + if role not in ROLES: + raise AuthError("role must be one of: %s" % ", ".join(ROLES)) + if not isinstance(password, str) or len(password) < MIN_PASSWORD_LEN: + raise AuthError("password must be at least %d characters" % MIN_PASSWORD_LEN) + if seat_limit is not None and self.count_active_users() >= seat_limit: + raise AuthError( + "seat limit reached (%d) — upgrade your Team license for more seats" + % seat_limit) + uid = "usr_" + secrets.token_hex(8) + try: + self.conn.execute( + "INSERT INTO users (id, email, name, role, pw_hash, created_at) " + "VALUES (?,?,?,?,?,?)", + (uid, email, name, role, + _hash_password(password, iterations=self.iterations), time.time())) + self.conn.commit() + except sqlite3.IntegrityError: + raise AuthError("a user with that email already exists") + return self.get_user(uid) + + def get_user(self, user_id: str) -> Optional[dict]: + row = self.conn.execute( + "SELECT id, email, name, role, created_at, disabled FROM users WHERE id=?", + (user_id,)).fetchone() + return dict(row) if row else None + + def list_users(self) -> list: + return [dict(r) for r in self.conn.execute( + "SELECT id, email, name, role, created_at, disabled FROM users " + "ORDER BY created_at")] + + def count_users(self) -> int: + return int(self.conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"]) + + def count_active_users(self) -> int: + return int(self.conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE disabled=0").fetchone()["n"]) + + def _count_active_admins(self) -> int: + return int(self.conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE role='admin' AND disabled=0" + ).fetchone()["n"]) + + def update_user(self, user_id: str, *, role: Optional[str] = None, + disabled: Optional[bool] = None) -> dict: + user = self.get_user(user_id) + if user is None: + raise AuthError("no such user") + # Never let the last active admin lock everyone out. + losing_admin = (user["role"] == "admin" and not user["disabled"] and + ((role is not None and role != "admin") or disabled)) + if losing_admin and self._count_active_admins() <= 1: + raise AuthError("cannot demote or disable the last active admin") + if role is not None: + if role not in ROLES: + raise AuthError("role must be one of: %s" % ", ".join(ROLES)) + self.conn.execute("UPDATE users SET role=? WHERE id=?", (role, user_id)) + if disabled is not None: + self.conn.execute("UPDATE users SET disabled=? WHERE id=?", + (1 if disabled else 0, user_id)) + if disabled: + self.revoke_user_sessions(user_id) + self.conn.commit() + return self.get_user(user_id) + + # ── login throttle (in-memory, per-process) ──────────────────────────────── + def _locked_until(self, email: str) -> float: + now = time.time() + fails = [t for t in self._failures.get(email, []) if now - t < LOCKOUT_WINDOW] + self._failures[email] = fails + if len(fails) >= LOCKOUT_FAILS: + return fails[-1] + LOCKOUT_SECONDS + return 0.0 + + def login(self, email: str, password: str) -> dict: + """Verify credentials → new session. Raises :class:`AuthError` (generic message — + never reveals which of email/password was wrong) or the lockout notice.""" + email = self._clean_email(email) + until = self._locked_until(email) + if until > time.time(): + raise AuthError("too many failed attempts — try again in a minute") + row = self.conn.execute( + "SELECT id, pw_hash, disabled FROM users WHERE email=?", (email,)).fetchone() + # Always run one PBKDF2 even for unknown emails (no user-enumeration timing). + encoded = row["pw_hash"] if row else _hash_password("x", iterations=self.iterations) + ok = _verify_password(password or "", encoded) + if not ok or row is None or row["disabled"]: + self._failures.setdefault(email, []).append(time.time()) + raise AuthError("invalid email or password") + self._failures.pop(email, None) + token = self.create_session(row["id"]) + user = self.get_user(row["id"]) + user["token"] = token + return user + + # ── sessions (raw token to the client, hash in the DB) ───────────────────── + def create_session(self, user_id: str, *, ttl: int = SESSION_TTL_SECONDS) -> str: + token = secrets.token_urlsafe(32) + now = time.time() + self.conn.execute( + "INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) " + "VALUES (?,?,?,?)", (_hash_token(token), user_id, now, now + ttl)) + self.conn.execute("DELETE FROM auth_sessions WHERE expires_at < ?", (now,)) + self.conn.commit() + return token + + def resolve_session(self, token: str) -> Optional[dict]: + if not token: + return None + row = self.conn.execute( + "SELECT s.user_id, s.expires_at, u.disabled FROM auth_sessions s " + "JOIN users u ON u.id = s.user_id WHERE s.token_hash=?", + (_hash_token(token),)).fetchone() + if row is None or row["expires_at"] < time.time() or row["disabled"]: + return None + return self.get_user(row["user_id"]) + + def revoke_session(self, token: str) -> None: + self.conn.execute("DELETE FROM auth_sessions WHERE token_hash=?", + (_hash_token(token),)) + self.conn.commit() + + def revoke_user_sessions(self, user_id: str) -> None: + self.conn.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,)) + self.conn.commit() diff --git a/engraphis/inspector/index.html b/engraphis/inspector/index.html index ad76d94..43f3d7c 100644 --- a/engraphis/inspector/index.html +++ b/engraphis/inspector/index.html @@ -29,7 +29,7 @@ header h1 .dot{color:var(--accent)} .scope{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-left:auto} label{color:var(--muted);font-size:13px} -select,input[type=text],input[type=search],textarea{ +select,input[type=text],input[type=search],input[type=password],input[type=email],textarea{ background:var(--panel-2);color:var(--text);border:1px solid var(--border); border-radius:8px;padding:7px 10px;font-size:14px;min-width:0} textarea{width:100%;font-family:inherit;resize:vertical} @@ -41,12 +41,23 @@ button.primary{background:var(--accent);border-color:var(--accent);color:#fff} button.danger{border-color:var(--danger);color:var(--danger)} button[disabled]{opacity:.5;cursor:not-allowed} +.plan-btn{border-radius:999px;padding:4px 14px;font-weight:600;font-size:12px; + letter-spacing:.6px;text-transform:uppercase} +.plan-btn.free{border-color:var(--accent);color:var(--accent)} +.plan-btn.paid{border-color:var(--accent-2);color:var(--accent-2)} +.chip{display:inline-flex;gap:8px;align-items:center;border:1px solid var(--border); + border-radius:999px;padding:3px 6px 3px 12px;font-size:13px;color:var(--muted)} +.chip .role{color:var(--accent-2);text-transform:capitalize} +.chip button{padding:2px 10px;font-size:12px;border-radius:999px} nav[role=tablist]{display:flex;gap:4px;padding:10px 20px 0;flex-wrap:wrap; border-bottom:1px solid var(--border);background:var(--panel)} [role=tab]{border:1px solid transparent;border-bottom:none;background:none; - color:var(--muted);padding:8px 14px;border-radius:8px 8px 0 0;cursor:pointer;font-size:14px} + color:var(--muted);padding:8px 14px;border-radius:8px 8px 0 0;cursor:pointer;font-size:14px; + display:inline-flex;align-items:center;gap:7px} [role=tab][aria-selected=true]{color:var(--text);background:var(--bg); border-color:var(--border)} +.pro-tag{font-size:10px;font-weight:700;letter-spacing:.8px;color:var(--accent-2); + border:1px solid var(--accent-2);border-radius:999px;padding:0 7px;line-height:1.5} main{padding:20px;max-width:1200px;margin:0 auto} section[role=tabpanel]{display:none} section[role=tabpanel].active{display:block} @@ -59,6 +70,12 @@ .card .title{font-weight:600} .card .content{color:var(--text);white-space:pre-wrap;word-break:break-word; max-height:9em;overflow:hidden} +.teaser{border-color:var(--accent-2);max-width:640px} +.teaser .title{color:var(--accent-2)} +.feature-list{list-style:none;margin:8px 0;padding:0;display:flex; + flex-direction:column;gap:6px;font-size:14px} +.feature-list .ok::before{content:"✓ ";color:var(--ok)} +.feature-list .lock::before{content:"🔒 ";font-size:12px} .meta{display:flex;gap:6px;flex-wrap:wrap;font-size:12px;color:var(--muted)} .badge{border:1px solid var(--border);border-radius:999px;padding:1px 9px;font-size:12px} .badge.mtype-semantic{border-color:var(--accent);color:var(--accent)} @@ -73,6 +90,9 @@ .badge.pinned{border-color:var(--accent-2);color:var(--accent-2)} .empty,.loading{color:var(--muted);padding:30px;text-align:center; border:1px dashed var(--border);border-radius:var(--radius)} +.first-run{max-width:680px;margin:30px auto;text-align:left} +.first-run pre{background:var(--panel-2);border:1px solid var(--border); + border-radius:8px;padding:12px;overflow:auto;font-family:var(--mono);font-size:13px} .chain{display:flex;flex-direction:column;gap:0;margin-top:10px} .chain-node{position:relative;border:1px solid var(--border);border-radius:var(--radius); background:var(--panel);padding:14px;margin-left:26px} @@ -108,10 +128,28 @@ padding:14px} .stat .num{font-size:26px;font-weight:700} .stat .lbl{color:var(--muted);font-size:13px} +.stat .sub{color:var(--muted);font-size:12px;margin-top:2px} .hint{color:var(--muted);font-size:13px;margin:6px 0 14px} fieldset{border:1px solid var(--border);border-radius:var(--radius);padding:12px; display:flex;gap:10px;flex-wrap:wrap;align-items:end} legend{color:var(--muted);font-size:13px;padding:0 6px} +.chart-card{background:var(--panel);border:1px solid var(--border); + border-radius:var(--radius);padding:14px;margin-bottom:14px} +.chart-card h3{margin:0 0 8px;font-size:14px} +.hbar{display:grid;grid-template-columns:110px 1fr 44px;gap:8px;align-items:center; + font-size:13px;margin:4px 0} +.hbar .track{background:var(--panel-2);border-radius:6px;height:14px;overflow:hidden} +.hbar .fill{height:100%;border-radius:6px;background:var(--accent)} +.hbar .n{color:var(--muted);text-align:right;font-family:var(--mono)} +.overlay{position:fixed;inset:0;background:var(--bg);z-index:40;display:flex; + align-items:center;justify-content:center;padding:20px} +.auth-card{background:var(--panel);border:1px solid var(--border); + border-radius:var(--radius);padding:28px;max-width:400px;width:100%; + display:flex;flex-direction:column;gap:12px} +.auth-card h2{margin:0;font-size:18px} +.auth-card h2 .dot{color:var(--accent)} +.auth-card form{display:flex;flex-direction:column;gap:10px} +.auth-card input{width:100%} @media (max-width:720px){ header{gap:8px} main{padding:12px} .kv{grid-template-columns:1fr} } @@ -119,6 +157,9 @@

Engraphis·Memory Inspector

+ +
@@ -133,8 +174,11 @@

Engraphis·Memory Inspector

+ +
@@ -183,6 +227,15 @@

Engraphis·Memory Inspector

+
+
+ +
+

Memory health at a glance: growth, retention distribution, what the decay + model will archive next, resolver activity, and the busiest entities.

+
+
+
@@ -201,12 +254,21 @@

Engraphis·Memory Inspector

-
+
+ + +

Every governance and resolver action — invalidations, corrections, forgets, pins, evolution links — with actor and reason. Nothing is ever silently - deleted.

+ deleted. Export produces the full bi-temporal dump (memories, history, audit) as JSON.

+ +
+

Multi-user access to this Inspector: admins manage users and licenses, + members govern memories, viewers read. Roles are enforced server-side on every request.

+
+
@@ -217,6 +279,39 @@

Memory

+ +
+

License & plan

+ +
+
+
+ + +
diff --git a/engraphis/licensing.py b/engraphis/licensing.py new file mode 100644 index 0000000..f857985 --- /dev/null +++ b/engraphis/licensing.py @@ -0,0 +1,351 @@ +"""Offline license verification for the Engraphis paid tiers (docs/LAUNCH_PLAN.md §2). + +Open-core with signed keys: the free core is complete and Apache-2.0; Pro/Team features +activate with an ``ENGR1..`` key whose JSON payload is Ed25519-signed +by the vendor. Verification is **offline and pure stdlib** — no phone-home, no license +server, no new dependency — so the numpy-only core guarantee (AGENTS.md §3) and the +local-first promise both hold. A determined user can fork the gate out (Apache-2.0); that +is the accepted Sidekiq-style trade — we sell convenience and support, not DRM. + +Ed25519 is implemented here from RFC 8032 (verify *and* sign — the vendor CLI +``scripts/license_admin.py`` reuses the same math; the private key itself never ships). +It is the reference algorithm, exercised against the RFC's own test vectors in +``tests/test_licensing.py``. Speed is irrelevant at one verify per process start. + +Key resolution order: ``ENGRAPHIS_LICENSE_KEY`` env var, then ``~/.engraphis/license.key``. +Feature gates call :func:`has_feature` / :func:`require_feature`; the free tier is the +absence of a license, never an error. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +# ── Ed25519 (RFC 8032, ref. implementation style; verify-grade, not constant-time — +# fine here: signing happens only vendor-side, verifying uses only public data) ────── + +_P = 2**255 - 19 +_L = 2**252 + 27742317777372353535851937790883648493 +_D = (-121665 * pow(121666, _P - 2, _P)) % _P +_I = pow(2, (_P - 1) // 4, _P) + + +def _sha512(data: bytes) -> bytes: + return hashlib.sha512(data).digest() + + +def _recover_x(y: int, sign: int) -> int: + if y >= _P: + raise ValueError("invalid point encoding") + x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P + if x2 == 0: + if sign: + raise ValueError("invalid point encoding") + return 0 + x = pow(x2, (_P + 3) // 8, _P) + if (x * x - x2) % _P != 0: + x = x * _I % _P + if (x * x - x2) % _P != 0: + raise ValueError("invalid point encoding") + if (x & 1) != sign: + x = _P - x + return x + + +# Points are extended homogeneous coordinates (X, Y, Z, T), RFC 8032 §5.1.4. +def _pt_add(p, q): + a = (p[1] - p[0]) * (q[1] - q[0]) % _P + b = (p[1] + p[0]) * (q[1] + q[0]) % _P + c = 2 * p[3] * q[3] * _D % _P + d = 2 * p[2] * q[2] % _P + e, f, g, h = b - a, d - c, d + c, b + a + return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) + + +def _pt_mul(p, s: int): + q = (0, 1, 1, 0) # neutral element + while s > 0: + if s & 1: + q = _pt_add(q, p) + p = _pt_add(p, p) + s >>= 1 + return q + + +def _pt_equal(p, q) -> bool: + return ((p[0] * q[2] - q[0] * p[2]) % _P == 0 and + (p[1] * q[2] - q[1] * p[2]) % _P == 0) + + +_BY = 4 * pow(5, _P - 2, _P) % _P +_BX = _recover_x(_BY, 0) +_B = (_BX, _BY, 1, _BX * _BY % _P) + + +def _pt_encode(p) -> bytes: + zinv = pow(p[2], _P - 2, _P) + x, y = p[0] * zinv % _P, p[1] * zinv % _P + return (y | ((x & 1) << 255)).to_bytes(32, "little") + + +def _pt_decode(raw: bytes): + if len(raw) != 32: + raise ValueError("invalid point encoding") + val = int.from_bytes(raw, "little") + sign = val >> 255 + y = val & ((1 << 255) - 1) + x = _recover_x(y, sign) + return (x, y, 1, x * y % _P) + + +def ed25519_public_key(secret: bytes) -> bytes: + """Derive the 32-byte public key from a 32-byte secret seed.""" + if len(secret) != 32: + raise ValueError("secret key must be 32 bytes") + h = _sha512(secret) + a = int.from_bytes(h[:32], "little") + a &= (1 << 254) - 8 + a |= 1 << 254 + return _pt_encode(_pt_mul(_B, a)) + + +def ed25519_sign(secret: bytes, message: bytes) -> bytes: + """RFC 8032 sign (vendor-side only; used by scripts/license_admin.py).""" + h = _sha512(secret) + a = int.from_bytes(h[:32], "little") + a &= (1 << 254) - 8 + a |= 1 << 254 + prefix = h[32:] + pub = _pt_encode(_pt_mul(_B, a)) + r = int.from_bytes(_sha512(prefix + message), "little") % _L + rp = _pt_encode(_pt_mul(_B, r)) + k = int.from_bytes(_sha512(rp + pub + message), "little") % _L + s = (r + k * a) % _L + return rp + s.to_bytes(32, "little") + + +def ed25519_verify(public: bytes, message: bytes, signature: bytes) -> bool: + """RFC 8032 verify. Returns False (never raises) on any malformed input.""" + if len(public) != 32 or len(signature) != 64: + return False + try: + a = _pt_decode(public) + rp = _pt_decode(signature[:32]) + except ValueError: + return False + s = int.from_bytes(signature[32:], "little") + if s >= _L: + return False + k = int.from_bytes(_sha512(signature[:32] + public + message), "little") % _L + return _pt_equal(_pt_mul(_B, s), _pt_add(rp, _pt_mul(a, k))) + + +# ── feature registry (docs/LAUNCH_PLAN.md §2 — keep the free/paid line stable) ──────── + +#: Paid features that exist today, with the one-line description the UI shows. +FEATURES: dict = { + "analytics": "Analytics dashboard — growth, retention distribution, decay forecast", + "export": "Compliance export — full bi-temporal workspace dump (memories + audit)", + "team": "Team mode — multi-user Inspector with logins and roles", +} + +#: What each plan unlocks. Unknown feature names in a key are carried but inert. +PLAN_FEATURES: dict = { + "pro": frozenset({"analytics", "export"}), + "team": frozenset({"analytics", "export", "team"}), +} + +#: Where to buy — shown by the Inspector's license dialog and error messages. +PURCHASE_URL = "https://engraphis.dev/pro" # TODO: set the real URL before launch + +_KEY_PREFIX = "ENGR1" +# Dev/default vendor public key. ROTATE BEFORE SELLING (docs/LAUNCH_PLAN.md §6.2): +# run `python -m scripts.license_admin keygen` and replace this constant. +_VENDOR_PUBKEY_HEX = "4722dc145d7b988f6a2513e750e367beb2dd75a68a208c8546b1fbb61c862b7e" + +_LICENSE_FILE = Path.home() / ".engraphis" / "license.key" + + +class LicenseError(Exception): + """Invalid, tampered, or expired license key. Message is safe to surface.""" + + +@dataclass(frozen=True) +class License: + """A verified license. ``License.free()`` is the tier-0 sentinel, not an error.""" + + plan: str = "free" + email: str = "" + seats: int = 1 + issued: Optional[float] = None + expires: Optional[float] = None + features: frozenset = field(default_factory=frozenset) + key_id: str = "" # short fingerprint for support/display; never the key itself + + @classmethod + def free(cls) -> "License": + return cls() + + @property + def is_paid(self) -> bool: + return self.plan != "free" + + def has(self, feature: str) -> bool: + return feature in self.features + + def to_public_dict(self) -> dict: + """JSON-able summary for UIs. Contains no key material.""" + return { + "plan": self.plan, "email": self.email, "seats": self.seats, + "expires": self.expires, "features": sorted(self.features), + "key_id": self.key_id, "purchase_url": PURCHASE_URL, + "known_features": FEATURES, + } + + +def _b64u_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64u_decode(text: str) -> bytes: + pad = "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(text + pad) + + +def vendor_public_key() -> bytes: + """Pinned vendor key; ``ENGRAPHIS_LICENSE_PUBKEY`` (hex) overrides for rotation/tests.""" + hexkey = os.environ.get("ENGRAPHIS_LICENSE_PUBKEY", "").strip() or _VENDOR_PUBKEY_HEX + try: + raw = bytes.fromhex(hexkey) + except ValueError: + raise LicenseError("vendor public key is not valid hex") + if len(raw) != 32: + raise LicenseError("vendor public key must be 32 bytes") + return raw + + +def compose_key(payload: dict, secret: bytes) -> str: + """Build a signed key from a payload dict (vendor-side; see license_admin).""" + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + sig = ed25519_sign(secret, body) + return "%s.%s.%s" % (_KEY_PREFIX, _b64u_encode(body), _b64u_encode(sig)) + + +def parse_key(key: str, *, now: Optional[float] = None) -> License: + """Verify ``key`` and return its :class:`License`. Raises :class:`LicenseError`.""" + key = (key or "").strip() + if not key: + raise LicenseError("empty license key") + parts = key.split(".") + if len(parts) != 3 or parts[0] != _KEY_PREFIX: + raise LicenseError("not an Engraphis license key (expected ENGR1..)") + try: + body = _b64u_decode(parts[1]) + sig = _b64u_decode(parts[2]) + except (ValueError, base64.binascii.Error): + raise LicenseError("license key is not valid base64url") + if not ed25519_verify(vendor_public_key(), body, sig): + raise LicenseError("license signature is invalid (tampered or wrong vendor key)") + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise LicenseError("license payload is not valid JSON") + if not isinstance(payload, dict) or payload.get("v") != 1: + raise LicenseError("unsupported license payload version") + + plan = str(payload.get("plan", "")).lower() + if plan not in PLAN_FEATURES: + raise LicenseError("unknown plan '%s'" % plan) + expires = payload.get("expires") + if expires is not None: + try: + expires = float(expires) + except (TypeError, ValueError): + raise LicenseError("invalid expiry in license") + if (now if now is not None else time.time()) > expires: + raise LicenseError("license expired on %s — renew at %s" % ( + time.strftime("%Y-%m-%d", time.gmtime(expires)), PURCHASE_URL)) + extra = payload.get("features") or [] + if not isinstance(extra, list): + raise LicenseError("invalid features list in license") + features = frozenset(PLAN_FEATURES[plan]) | frozenset(str(f) for f in extra) + try: + seats = max(1, int(payload.get("seats", 1))) + except (TypeError, ValueError): + seats = 1 + return License( + plan=plan, email=str(payload.get("email", "")), seats=seats, + issued=payload.get("issued"), expires=expires, features=features, + key_id=hashlib.sha256(key.encode("ascii")).hexdigest()[:12], + ) + + +# ── process-wide current license (cached; free tier on any failure) ─────────────────── + +_cached: Optional[License] = None +_cache_error: str = "" + + +def _read_key_material() -> str: + env = os.environ.get("ENGRAPHIS_LICENSE_KEY", "").strip() + if env: + return env + try: + return _LICENSE_FILE.read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def current_license(*, refresh: bool = False) -> License: + """The verified license for this process, or ``License.free()``. Never raises — + a bad key degrades to the free tier and the reason is kept in :func:`license_error`.""" + global _cached, _cache_error + if _cached is not None and not refresh: + return _cached + material = _read_key_material() + if not material: + _cached, _cache_error = License.free(), "" + return _cached + try: + _cached, _cache_error = parse_key(material), "" + except LicenseError as exc: + _cached, _cache_error = License.free(), str(exc) + return _cached + + +def license_error() -> str: + """Why the configured key (if any) was rejected — '' when none/valid.""" + current_license() + return _cache_error + + +def activate(key: str) -> License: + """Verify ``key``, persist it to ``~/.engraphis/license.key``, refresh the cache.""" + parse_key(key) # raises LicenseError if bad — nothing persisted then + _LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True) + _LICENSE_FILE.write_text(key.strip() + "\n", encoding="utf-8") + try: + os.chmod(_LICENSE_FILE, 0o600) + except OSError: # e.g. some Windows filesystems + pass + return current_license(refresh=True) + + +def has_feature(feature: str) -> bool: + return current_license().has(feature) + + +def require_feature(feature: str) -> None: + """Raise :class:`LicenseError` with an actionable message if ``feature`` is locked.""" + if not has_feature(feature): + desc = FEATURES.get(feature, feature) + raise LicenseError( + "'%s' is an Engraphis Pro feature (%s). Unlock at %s, then paste your key " + "in the Inspector's license dialog or set ENGRAPHIS_LICENSE_KEY." + % (feature, desc, PURCHASE_URL)) diff --git a/engraphis/service.py b/engraphis/service.py index c78a4d3..50f20ee 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -359,35 +359,41 @@ def end_session(self, session_id: str, *, summary: str = "", outcome: str = "", # ── governance: forget / pin / correct (audited, never a silent hard delete) ── def forget(self, memory_id: str, *, workspace: str, repo: Optional[str] = None, - reason: str = "") -> dict: + reason: str = "", actor: str = "user") -> dict: mid = _clean_text(memory_id, field="memory_id", max_chars=MAX_NAME_CHARS) reason = _clean_text(reason, field="reason", max_chars=MAX_TITLE_CHARS, required=False) + actor = _clean_text(actor, field="actor", max_chars=MAX_NAME_CHARS, + required=False) or "user" wid, rid = self._require_scope(workspace, repo) self._check_owns(mid, wid, rid) try: - return self.engine.forget(mid, reason=reason) + return self.engine.forget(mid, reason=reason, actor=actor) except KeyError as exc: raise ValidationError(str(exc)) def pin(self, memory_id: str, *, workspace: str, repo: Optional[str] = None, - pinned: bool = True) -> dict: + pinned: bool = True, actor: str = "user") -> dict: mid = _clean_text(memory_id, field="memory_id", max_chars=MAX_NAME_CHARS) + actor = _clean_text(actor, field="actor", max_chars=MAX_NAME_CHARS, + required=False) or "user" wid, rid = self._require_scope(workspace, repo) self._check_owns(mid, wid, rid) try: - return self.engine.pin(mid, pinned=bool(pinned)) + return self.engine.pin(mid, pinned=bool(pinned), actor=actor) except KeyError as exc: raise ValidationError(str(exc)) def correct(self, memory_id: str, new_content: str, *, workspace: str, - repo: Optional[str] = None, reason: str = "") -> dict: + repo: Optional[str] = None, reason: str = "", actor: str = "user") -> dict: mid = _clean_text(memory_id, field="memory_id", max_chars=MAX_NAME_CHARS) new_content = _clean_text(new_content, field="new_content", max_chars=MAX_CONTENT_CHARS) reason = _clean_text(reason, field="reason", max_chars=MAX_TITLE_CHARS, required=False) + actor = _clean_text(actor, field="actor", max_chars=MAX_NAME_CHARS, + required=False) or "user" wid, rid = self._require_scope(workspace, repo) self._check_owns(mid, wid, rid) try: - return self.engine.correct(mid, new_content, reason=reason) + return self.engine.correct(mid, new_content, reason=reason, actor=actor) except KeyError as exc: raise ValidationError(str(exc)) @@ -584,6 +590,27 @@ def audit_log(self, *, workspace: str, limit: int = 100) -> dict: "ORDER BY a.ts DESC LIMIT ?", (wid, limit)).fetchall() return {"entries": [dict(r) for r in rows]} + def export_workspace(self, *, workspace: str) -> dict: + """Full bi-temporal dump of one workspace — memories (live *and* superseded), + sessions, and the audit trail. The compliance story in one artifact: nothing is + ever silently deleted, and the export proves it. Scope-checked like any other + read; the Pro license gate lives in the HTTP layer (inspector/app.py), keeping + the service honest for OSS callers.""" + wid, _ = self._require_scope(workspace, None) + conn = self.store.conn + memories = [dict(r) for r in conn.execute( + "SELECT * FROM memories WHERE workspace_id=? ORDER BY rowid", (wid,))] + sessions = [dict(r) for r in conn.execute( + "SELECT * FROM sessions WHERE workspace_id=? ORDER BY rowid", (wid,))] + audit = [dict(r) for r in conn.execute( + "SELECT a.* FROM audit a JOIN memories m ON m.id = a.target " + "WHERE m.workspace_id=? ORDER BY a.ts", (wid,))] + import time as _time + return {"format": "engraphis-export/1", "exported_at": _time.time(), + "workspace": workspace, "counts": {"memories": len(memories), + "sessions": len(sessions), "audit": len(audit)}, + "memories": memories, "sessions": sessions, "audit": audit} + # ── introspection ─────────────────────────────────────────────────────────── def stats(self, *, workspace: Optional[str] = None) -> dict: """Counts for quick health/onboarding checks (read-only).""" diff --git a/pyproject.toml b/pyproject.toml index 4c4a7cb..6736a82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ engraphis-cli = "scripts.cli:main" engraphis-mcp = "engraphis.mcp_server:main" engraphis-inspector = "scripts.inspector:main" engraphis-consolidate = "scripts.consolidate:main" +engraphis-init = "scripts.init:main" [tool.setuptools.packages.find] include = ["engraphis*", "scripts*"] diff --git a/scripts/init.py b/scripts/init.py new file mode 100644 index 0000000..b86ab24 --- /dev/null +++ b/scripts/init.py @@ -0,0 +1,158 @@ +"""engraphis-init — one command from `pip install` to a configured, agent-connected setup. + +Closes the biggest first-run gap (docs/LAUNCH_PLAN.md §4.2): without configuration the +DB path defaults to the *package* directory, which is site-packages on a pip install. +This writes a project-local `.env` (absolute `ENGRAPHIS_DB_PATH`, optional API token), +then prints the exact MCP snippets to paste into Claude Code / Cursor / Cline / Zed. + + engraphis-init # write ./.env (kept if it exists), print next steps + engraphis-init --db ~/mem.db # choose the database location + engraphis-init --token # also generate a bearer token for the HTTP APIs + engraphis-init --force # overwrite an existing .env + engraphis-init --check # doctor: verify install, extras, DB writability + +Non-interactive by design (no prompts): safe in scripts, CI, and agent shells. +""" +from __future__ import annotations + +import argparse +import json +import secrets +import sqlite3 +import sys +from pathlib import Path + + +def _ok(label: str, detail: str = "") -> None: + print(f" [ok] {label}" + (f" — {detail}" if detail else "")) + + +def _miss(label: str, detail: str = "") -> None: + print(f" [--] {label}" + (f" — {detail}" if detail else "")) + + +def _fail(label: str, detail: str = "") -> None: + print(f" [FAIL] {label}" + (f" — {detail}" if detail else "")) + + +def _try_import(name: str): + try: + return __import__(name) + except Exception: + return None + + +def cmd_check() -> int: + """Doctor: report what's installed and whether the configured DB is usable.""" + failures = 0 + print(f"engraphis doctor — python {sys.version.split()[0]}") + + if _try_import("numpy") is None: + _fail("numpy (required core)", "pip install numpy") + failures += 1 + else: + _ok("numpy (required core)") + + for mod, label, hint in [ + ("mcp", "MCP server extra", 'pip install "engraphis[mcp]"'), + ("fastapi", "REST/Inspector extra", 'pip install "engraphis[server]"'), + ("sentence_transformers", "real embeddings", + "optional — deterministic offline embedder is the fallback"), + ("tree_sitter", "AST code indexing", + "optional — regex code indexer is the fallback"), + ]: + (_ok if _try_import(mod) else _miss)(label, hint if not _try_import(mod) else "") + + from engraphis.config import settings + db = Path(settings.db_path).expanduser() + try: + db.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db)) + conn.execute("PRAGMA user_version") + conn.close() + _ok("database writable", str(db)) + except Exception as exc: + _fail("database writable", f"{db}: {exc}") + failures += 1 + + try: + from engraphis import licensing + lic = licensing.current_license(refresh=True) + err = licensing.license_error() + if err: + _miss("license", f"key rejected: {err}") + else: + _ok("license", f"{lic.plan}" + ( + f" ({', '.join(sorted(lic.features))})" if lic.features else + " — free tier, core fully functional")) + except Exception as exc: # never let licensing break the doctor + _miss("license", str(exc)) + + print("all good ✓" if failures == 0 else f"{failures} problem(s) found") + return 0 if failures == 0 else 1 + + +def _env_content(db_path: Path, token: str) -> str: + lines = [ + "# Engraphis — generated by engraphis-init. Full reference: .env.example", + f"ENGRAPHIS_DB_PATH={db_path}", + ] + if token: + lines += [ + "# Bearer token required by the REST server & Inspector APIs:", + f"ENGRAPHIS_API_TOKEN={token}", + ] + lines += [ + "# Unlock Pro/Team (analytics, export, multi-user) — https://engraphis.dev/pro", + "# ENGRAPHIS_LICENSE_KEY=ENGR1.xxxxx.yyyyy", + "# ENGRAPHIS_TEAM_MODE=1", + ] + return "\n".join(lines) + "\n" + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="engraphis-init", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--db", default="engraphis.db", + help="database file (default: ./engraphis.db)") + ap.add_argument("--token", action="store_true", + help="generate an ENGRAPHIS_API_TOKEN for the HTTP APIs") + ap.add_argument("--force", action="store_true", help="overwrite an existing .env") + ap.add_argument("--check", action="store_true", + help="doctor mode: verify the installation instead of writing .env") + args = ap.parse_args(argv) + + if args.check: + return cmd_check() + + db_path = Path(args.db).expanduser().resolve() + env_file = Path.cwd() / ".env" + token = secrets.token_urlsafe(24) if args.token else "" + + if env_file.exists() and not args.force: + print(f".env already exists at {env_file} — kept (use --force to overwrite).") + else: + env_file.write_text(_env_content(db_path, token), encoding="utf-8") + print(f"wrote {env_file}") + print(f" database → {db_path}") + if token: + print(" api token → generated (in .env; send as 'Authorization: Bearer …')") + + snippet = {"mcpServers": {"engraphis": { + "command": "engraphis-mcp", + "env": {"ENGRAPHIS_DB_PATH": str(db_path)}, + }}} + print("\nConnect your agent — Claude Code:") + print(f' claude mcp add engraphis --env ENGRAPHIS_DB_PATH="{db_path}"' + " -- engraphis-mcp") + print("\nCursor / Cline / Zed / Windsurf (mcp config):") + print(json.dumps(snippet, indent=2)) + print("\nNext steps:") + print(" engraphis-inspector # product UI on http://127.0.0.1:8710") + print(" engraphis-init --check # verify the install") + print(" Free forever at the core — Pro/Team unlock: https://engraphis.dev/pro") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/license_admin.py b/scripts/license_admin.py new file mode 100644 index 0000000..213f4d6 --- /dev/null +++ b/scripts/license_admin.py @@ -0,0 +1,112 @@ +"""Vendor-side license CLI — keygen / issue / verify (docs/LAUNCH_PLAN.md §2). + +This is YOUR tool, not the customer's. The private signing key never ships and never +belongs in the repo: ``keygen`` writes it to ``.secrets/`` (gitignored) and prints the +public half to pin in ``engraphis/licensing.py``. + + python -m scripts.license_admin keygen + python -m scripts.license_admin issue --email a@b.co --plan team --seats 5 --days 365 + python -m scripts.license_admin verify ENGR1.xxxx.yyyy +""" +from __future__ import annotations + +import argparse +import json +import secrets +import sys +import time +from pathlib import Path + +from engraphis.licensing import ( + PLAN_FEATURES, LicenseError, compose_key, ed25519_public_key, parse_key, +) + +_DEFAULT_KEY_PATH = Path(__file__).resolve().parent.parent / ".secrets" / "vendor_signing.key" + + +def _load_secret(path: Path) -> bytes: + try: + raw = bytes.fromhex(path.read_text(encoding="utf-8").strip()) + except OSError: + sys.exit(f"no signing key at {path} — run `python -m scripts.license_admin keygen`") + except ValueError: + sys.exit(f"{path} is not valid hex") + if len(raw) != 32: + sys.exit(f"{path} must contain a 32-byte hex seed") + return raw + + +def cmd_keygen(args) -> None: + path = Path(args.key_file) + if path.exists() and not args.force: + sys.exit(f"{path} exists — pass --force to overwrite (this invalidates issued keys!)") + secret = secrets.token_bytes(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret.hex() + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + pass + pub = ed25519_public_key(secret).hex() + print(f"private signing key → {path} (KEEP OFF DEV BOXES; password manager)") + print(f"public verify key → {pub}") + print("pin it: set _VENDOR_PUBKEY_HEX in engraphis/licensing.py to the value above") + + +def cmd_issue(args) -> None: + if args.plan not in PLAN_FEATURES: + sys.exit(f"plan must be one of: {', '.join(sorted(PLAN_FEATURES))}") + secret = _load_secret(Path(args.key_file)) + now = time.time() + payload = { + "v": 1, "plan": args.plan, "email": args.email, + "seats": max(1, args.seats), "issued": int(now), + "expires": int(now + args.days * 86400) if args.days else None, + } + if args.feature: + payload["features"] = sorted(set(args.feature)) + key = compose_key(payload, secret) + print(key) + if args.json: + print(json.dumps(payload, indent=2), file=sys.stderr) + + +def cmd_verify(args) -> None: + try: + lic = parse_key(args.key) + except LicenseError as exc: + sys.exit(f"INVALID: {exc}") + print(json.dumps(lic.to_public_dict(), indent=2)) + + +def main(argv=None) -> None: + ap = argparse.ArgumentParser(prog="license_admin", description=__doc__) + sub = ap.add_subparsers(dest="cmd", required=True) + + kg = sub.add_parser("keygen", help="generate a vendor signing keypair") + kg.add_argument("--key-file", default=str(_DEFAULT_KEY_PATH)) + kg.add_argument("--force", action="store_true") + kg.set_defaults(fn=cmd_keygen) + + iss = sub.add_parser("issue", help="issue a signed license key") + iss.add_argument("--email", required=True) + iss.add_argument("--plan", required=True, help="pro | team") + iss.add_argument("--seats", type=int, default=1) + iss.add_argument("--days", type=int, default=365, + help="validity in days; 0 = perpetual") + iss.add_argument("--feature", action="append", + help="extra feature flag (repeatable)") + iss.add_argument("--key-file", default=str(_DEFAULT_KEY_PATH)) + iss.add_argument("--json", action="store_true", help="echo payload to stderr") + iss.set_defaults(fn=cmd_issue) + + ver = sub.add_parser("verify", help="verify a key against the pinned public key") + ver.add_argument("key") + ver.set_defaults(fn=cmd_verify) + + args = ap.parse_args(argv) + args.fn(args) + + +if __name__ == "__main__": + main() diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..41224c2 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,73 @@ +"""Analytics (Pro data layer) — pure-function math first, thin SQL wrapper second.""" +import math + +from engraphis.analytics import ( + FORGET_THRESHOLD, _days_until_forgotten, analytics_from_rows, compute_analytics, +) +from engraphis.service import MemoryService + +NOW = 1_750_000_000.0 + + +def _row(**kw): + base = {"mtype": "semantic", "stability": 1.0, "last_access": NOW, + "ingested_at": NOW, "importance": 0.0, "pinned": 0, + "valid_to": None, "expired_at": None} + base.update(kw) + return base + + +def test_days_until_forgotten_matches_ebbinghaus_inverse(): + # exp(-dt/S) = T → dt = S·ln(1/T); with S=1 and fresh access ≈ 3.0 days. + d = _days_until_forgotten(1.0, NOW, NOW) + assert abs(d - math.log(1.0 / FORGET_THRESHOLD)) < 1e-9 + # Already-elapsed time is subtracted. + d2 = _days_until_forgotten(1.0, NOW - 2 * 86400, NOW) + assert abs((d - d2) - 2.0) < 1e-9 + + +def test_analytics_from_rows_core_shape_and_counts(): + rows = [ + _row(stability=5.0), # ~15d horizon, live + _row(stability=1.0, last_access=NOW - 2.5 * 86400), # forgotten in ~0.5d → at risk + _row(stability=40.0), # safe for months + _row(pinned=1, stability=0.01), # pinned → exempt from forecast + _row(valid_to=NOW - 10, mtype="episodic"), # superseded → not live + ] + out = analytics_from_rows(rows, {"invalidate": 2, "noop": 3}, + [{"name": "Alice", "etype": "person", "n": 4}], now=NOW) + assert out["totals"]["all_rows"] == 5 + assert out["totals"]["live"] == 4 + assert out["totals"]["superseded"] == 1 + assert out["totals"]["pinned"] == 1 + assert out["decay_forecast"]["at_risk_7d"] == 1 # only the fast-decaying one + assert out["decay_forecast"]["at_risk_30d"] == 2 # + the ~15d-horizon one + assert len(out["growth_weekly"]) == 12 + assert out["growth_weekly"][-1] == 5 # all ingested "this week" + assert sum(out["retention_histogram"]["counts"]) == 4 # live only + assert out["by_type"] == {"semantic": 4} + assert out["resolver_mix"] == {"invalidate": 2, "noop": 3} + assert out["top_entities"][0]["name"] == "Alice" + + +def test_growth_buckets_place_old_memories_correctly(): + rows = [_row(ingested_at=NOW - 3 * 7 * 86400), # 3 weeks ago + _row(ingested_at=NOW - 100 * 7 * 86400)] # off the chart → dropped + out = analytics_from_rows(rows, {}, [], now=NOW) + assert out["growth_weekly"][-4] == 1 + assert sum(out["growth_weekly"]) == 1 + + +def test_compute_analytics_over_a_real_store(): + svc = MemoryService.create(":memory:") + svc.remember("Deploy target is region iad.", workspace="acme", repo="infra") + out2 = svc.remember("Deploy target is region fra as of March.", + workspace="acme", repo="infra") + assert out2["op"] == "invalidate" # supersession happened + wid = svc._lookup_workspace("acme") + data = compute_analytics(svc.store, wid) + assert data["totals"]["all_rows"] == 2 + assert data["totals"]["live"] == 1 + assert data["totals"]["superseded"] == 1 + assert data["resolver_mix"].get("invalidate", 0) >= 1 + assert data["totals"]["avg_retention"] > 0.9 # freshly written diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..ed124cb --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,37 @@ +"""engraphis-init — onboarding command. Runs on the numpy-only gate (stdlib only).""" +from scripts.init import main + + +def test_init_writes_env_with_absolute_db_path(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert main(["--db", "mem/engraphis.db"]) == 0 + env = (tmp_path / ".env").read_text() + out = capsys.readouterr().out + assert "ENGRAPHIS_DB_PATH=" in env + assert str((tmp_path / "mem" / "engraphis.db").resolve()) in env + assert "engraphis-mcp" in out and "mcpServers" in out # agent snippets printed + + +def test_init_never_clobbers_existing_env(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("ENGRAPHIS_DB_PATH=/keep/me.db\n") + assert main([]) == 0 + assert (tmp_path / ".env").read_text() == "ENGRAPHIS_DB_PATH=/keep/me.db\n" + assert main(["--force"]) == 0 # explicit opt-in overwrites + assert "/keep/me.db" not in (tmp_path / ".env").read_text() + + +def test_init_token_flag_generates_bearer_token(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert main(["--token"]) == 0 + assert "ENGRAPHIS_API_TOKEN=" in (tmp_path / ".env").read_text() + + +def test_doctor_runs_and_reports(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("ENGRAPHIS_DB_PATH", str(tmp_path / "doc.db")) + # settings is constructed at import; doctor re-reads env via a fresh Settings + import engraphis.config as cfg + monkeypatch.setattr(cfg, "settings", cfg.Settings()) + assert main(["--check"]) == 0 + out = capsys.readouterr().out + assert "numpy (required core)" in out and "database writable" in out diff --git a/tests/test_inspector_pro.py b/tests/test_inspector_pro.py new file mode 100644 index 0000000..be7ee7f --- /dev/null +++ b/tests/test_inspector_pro.py @@ -0,0 +1,218 @@ +"""Commercial layer of the Inspector — license gating (402), team auth, roles, seats. +Skips cleanly on the numpy-only CI gate, like test_inspector.py.""" +import time + +import pytest + +pytest.importorskip("fastapi", reason="full-stack extra not installed") +from fastapi.testclient import TestClient # noqa: E402 + +from engraphis import licensing # noqa: E402 +from engraphis.config import settings # noqa: E402 +from engraphis.inspector.app import create_app # noqa: E402 +from engraphis.inspector.auth import AuthStore # noqa: E402 +from engraphis.licensing import compose_key, ed25519_public_key # noqa: E402 +from engraphis.service import MemoryService # noqa: E402 + +SECRET = bytes(range(32)) +PW = "hunter2hunter2" # ≥ 10 chars + + +def _key(plan="pro", seats=10, days=365, **kw): + payload = {"v": 1, "plan": plan, "email": "buyer@x.co", "seats": seats, + "issued": int(time.time()), + "expires": int(time.time() + days * 86400) if days else None} + payload.update(kw) + return compose_key(payload, SECRET) + + +@pytest.fixture() +def make_client(monkeypatch): + """Factory: build an Inspector TestClient in a given commercial configuration.""" + def _make(*, key=None, team_mode=False, token=""): + monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", ed25519_public_key(SECRET).hex()) + if key: + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", key) + else: + monkeypatch.delenv("ENGRAPHIS_LICENSE_KEY", raising=False) + licensing.current_license(refresh=True) + monkeypatch.setattr(settings, "api_token", token) + monkeypatch.setattr(settings, "team_mode", team_mode) + svc = MemoryService.create(":memory:") + out = svc.remember("The rate limit is 500 rpm.", workspace="acme", repo="api") + app = create_app(svc, AuthStore(":memory:", iterations=1_000)) + return app, TestClient(app), out["id"] + yield _make + licensing.current_license(refresh=True) + + +# ── free tier: everything existing works; paid surfaces upsell, never break ────────── + +def test_free_tier_gates_pro_endpoints_with_402(make_client): + _, c, _ = make_client() + r = c.get("/api/analytics", params={"workspace": "acme"}) + assert r.status_code == 402 + body = r.json() + assert body["upgrade"] is True and "purchase_url" in body + assert c.get("/api/export", params={"workspace": "acme"}).status_code == 402 + # the free product is untouched + assert c.get("/api/recall", params={"q": "rate", "workspace": "acme"}).status_code == 200 + st = c.get("/api/auth/state").json() + assert st["mode"] == "open" and st["license"]["plan"] == "free" + + +def test_team_mode_without_team_license_reports_locked_not_broken(make_client): + _, c, _ = make_client(key=_key("pro"), team_mode=True) + st = c.get("/api/auth/state").json() + assert st["mode"] == "open" # gracefully NOT team + assert st["team_locked"] is True # …and the UI knows to show the unlock path + assert c.get("/api/recall", params={"q": "rate", "workspace": "acme"}).status_code == 200 + + +# ── pro tier ────────────────────────────────────────────────────────────────────────── + +def test_pro_key_unlocks_analytics_and_export(make_client): + _, c, _ = make_client(key=_key("pro")) + a = c.get("/api/analytics", params={"workspace": "acme"}) + assert a.status_code == 200 + data = a.json() + assert data["totals"]["live"] == 1 and len(data["growth_weekly"]) == 12 + e = c.get("/api/export", params={"workspace": "acme"}) + assert e.status_code == 200 + assert "attachment" in e.headers.get("content-disposition", "") + dump = e.json() + assert dump["format"] == "engraphis-export/1" and dump["counts"]["memories"] == 1 + + +def test_expired_key_degrades_to_free_with_reason(make_client): + _, c, _ = make_client(key=_key("pro", days=-1)) + st = c.get("/api/auth/state").json() + assert st["license"]["plan"] == "free" + assert "expired" in st["license_error"] + assert c.get("/api/analytics", params={"workspace": "acme"}).status_code == 402 + + +# ── team tier: sessions, roles, seats ───────────────────────────────────────────────── + +def _setup_admin(client): + r = client.post("/api/auth/setup", json={ + "email": "admin@x.co", "name": "Admin", "password": PW}) + assert r.status_code == 200 + return r + + +def test_team_flow_setup_login_roles_and_seats(make_client): + app, admin, mem_id = make_client(key=_key("team", seats=3), team_mode=True) + + st = admin.get("/api/auth/state").json() + assert st["mode"] == "team" and st["setup_required"] is True + # locked out before setup/login + assert admin.get("/api/recall", params={"q": "x", "workspace": "acme"}).status_code == 401 + + _setup_admin(admin) # first user = admin + cookie + assert admin.get("/api/auth/state").json()["user"]["role"] == "admin" + assert admin.get("/api/recall", + params={"q": "rate", "workspace": "acme"}).status_code == 200 + # setup is one-shot + r = admin.post("/api/auth/setup", json={"email": "e@x.co", "password": PW}) + assert r.status_code == 409 + + # admin provisions a member and a viewer (3 seats total — at the limit now) + for email, role in [("m@x.co", "member"), ("v@x.co", "viewer")]: + r = admin.post("/api/auth/users", json={ + "email": email, "name": email, "password": PW, "role": role}) + assert r.status_code == 200, r.text + # seat 4 exceeds the licensed 3 + r = admin.post("/api/auth/users", json={ + "email": "extra@x.co", "name": "x", "password": PW, "role": "viewer"}) + assert r.status_code == 400 and "seat limit" in r.json()["error"] + + member, viewer = TestClient(app), TestClient(app) + assert member.post("/api/auth/login", + json={"email": "m@x.co", "password": PW}).status_code == 200 + assert viewer.post("/api/auth/login", + json={"email": "v@x.co", "password": PW}).status_code == 200 + + govern = {"memory_id": mem_id, "workspace": "acme", "repo": "api"} + assert viewer.get("/api/recall", + params={"q": "rate", "workspace": "acme"}).status_code == 200 + assert viewer.post("/api/pin", json=govern).status_code == 403 # read-only + assert member.post("/api/pin", json=govern).status_code == 200 # governance ok + assert member.get("/api/auth/users").status_code == 403 # not admin + assert member.post("/api/consolidate", + json={"workspace": "acme"}).status_code == 403 + assert member.get("/api/export", params={"workspace": "acme"}).status_code == 403 + assert admin.get("/api/export", params={"workspace": "acme"}).status_code == 200 + + # last-active-admin protection + users = admin.get("/api/auth/users").json()["users"] + admin_id = next(u["id"] for u in users if u["role"] == "admin") + r = admin.post("/api/auth/users/update", json={"user_id": admin_id, "role": "member"}) + assert r.status_code == 400 and "last active admin" in r.json()["error"] + + # disable the viewer → their session dies with them + viewer_id = next(u["id"] for u in users if u["email"] == "v@x.co") + assert admin.post("/api/auth/users/update", + json={"user_id": viewer_id, "disabled": True}).status_code == 200 + assert viewer.get("/api/recall", + params={"q": "rate", "workspace": "acme"}).status_code == 401 + + # logout revokes the admin session + assert admin.post("/api/auth/logout").status_code == 200 + assert admin.get("/api/recall", + params={"q": "rate", "workspace": "acme"}).status_code == 401 + + +def test_login_throttle_locks_after_repeated_failures(make_client): + app, admin, _ = make_client(key=_key("team"), team_mode=True) + _setup_admin(admin) + attacker = TestClient(app) + for _ in range(5): + r = attacker.post("/api/auth/login", + json={"email": "admin@x.co", "password": "wrong-password"}) + assert r.status_code == 401 + r = attacker.post("/api/auth/login", + json={"email": "admin@x.co", "password": "wrong-password"}) + assert r.status_code == 429 + # correct password is ALSO locked out during the window (no oracle) + r = attacker.post("/api/auth/login", json={"email": "admin@x.co", "password": PW}) + assert r.status_code == 429 + + +def test_bearer_token_still_works_as_service_account_in_team_mode(make_client): + _, c, _ = make_client(key=_key("team"), team_mode=True, token="s3cret-token") + _setup_admin(c) + c.post("/api/auth/logout") + r = c.get("/api/recall", params={"q": "rate", "workspace": "acme"}, + headers={"Authorization": "Bearer s3cret-token"}) + assert r.status_code == 200 # scripts keep working + r = c.get("/api/recall", params={"q": "rate", "workspace": "acme"}, + headers={"Authorization": "Bearer wrong"}) + assert r.status_code == 401 + + +def test_activate_endpoint_persists_and_unlocks(make_client, monkeypatch, tmp_path): + monkeypatch.setattr(licensing, "_LICENSE_FILE", tmp_path / "license.key") + _, c, _ = make_client() # free tier + assert c.get("/api/analytics", params={"workspace": "acme"}).status_code == 402 + r = c.post("/api/license/activate", json={"key": "ENGR1.garbage.key"}) + assert r.status_code == 402 # bad key → upgrade payload + r = c.post("/api/license/activate", json={"key": _key("pro")}) + assert r.status_code == 200 and r.json()["license"]["plan"] == "pro" + assert c.get("/api/analytics", params={"workspace": "acme"}).status_code == 200 + + +def test_audit_attributes_governance_to_the_signed_in_user(make_client): + """Team tier's compliance story: the audit trail answers WHO, not just what.""" + app, admin, mem_id = make_client(key=_key("team"), team_mode=True) + _setup_admin(admin) + admin.post("/api/auth/users", json={ + "email": "m@x.co", "name": "M", "password": PW, "role": "member"}) + member = TestClient(app) + member.post("/api/auth/login", json={"email": "m@x.co", "password": PW}) + r = member.post("/api/pin", json={ + "memory_id": mem_id, "workspace": "acme", "repo": "api"}) + assert r.status_code == 200 + entries = admin.get("/api/audit", params={"workspace": "acme"}).json()["entries"] + pin_entries = [e for e in entries if e["action"] == "pin"] + assert pin_entries and pin_entries[0]["actor"] == "m@x.co" diff --git a/tests/test_licensing.py b/tests/test_licensing.py new file mode 100644 index 0000000..2777430 --- /dev/null +++ b/tests/test_licensing.py @@ -0,0 +1,156 @@ +"""Licensing tests — RFC 8032 vectors, issue/verify roundtrip, gates. Runs on the +numpy-only CI gate (pure stdlib, like the module under test).""" +import time + +import pytest + +from engraphis import licensing as lic +from engraphis.licensing import ( + License, LicenseError, compose_key, current_license, ed25519_public_key, + ed25519_sign, ed25519_verify, has_feature, parse_key, require_feature, +) + +# ── RFC 8032 §7.1 test vectors (TEST 1–3) — the crypto must match the spec exactly ──── +_VECTORS = [ + ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bac" + "c61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"), + ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e" + "458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"), + ("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290" + "ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), +] + + +@pytest.mark.parametrize("sk,pk,msg,sig", _VECTORS) +def test_rfc8032_vectors(sk, pk, msg, sig): + sk, pk = bytes.fromhex(sk), bytes.fromhex(pk) + msg, sig = bytes.fromhex(msg), bytes.fromhex(sig) + assert ed25519_public_key(sk) == pk + assert ed25519_sign(sk, msg) == sig + assert ed25519_verify(pk, msg, sig) + assert not ed25519_verify(pk, msg + b"!", sig) # message tamper + bad_sig = bytes([sig[0] ^ 1]) + sig[1:] + assert not ed25519_verify(pk, msg, bad_sig) # signature tamper + + +def test_verify_rejects_malformed_inputs_without_raising(): + assert not ed25519_verify(b"short", b"m", b"s" * 64) + assert not ed25519_verify(b"\xff" * 32, b"m", b"\xff" * 64) # non-canonical junk + + +# ── license keys ─────────────────────────────────────────────────────────────────────── + +SECRET = bytes(range(32)) # deterministic test vendor keypair + + +@pytest.fixture(autouse=True) +def _test_vendor_key(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", ed25519_public_key(SECRET).hex()) + monkeypatch.delenv("ENGRAPHIS_LICENSE_KEY", raising=False) + lic.current_license(refresh=True) + yield + lic.current_license(refresh=True) + + +def _issue(plan="pro", days=365, **kw): + payload = {"v": 1, "plan": plan, "email": "t@x.co", "seats": kw.pop("seats", 1), + "issued": int(time.time()), + "expires": int(time.time() + days * 86400) if days else None} + payload.update(kw) + return compose_key(payload, SECRET) + + +def test_roundtrip_pro_key(): + parsed = parse_key(_issue("pro")) + assert parsed.plan == "pro" and parsed.is_paid + assert parsed.has("analytics") and parsed.has("export") and not parsed.has("team") + assert parsed.key_id and "ENGR1" not in parsed.to_public_dict().values() + + +def test_team_plan_includes_pro_features_and_seats(): + parsed = parse_key(_issue("team", seats=7)) + assert parsed.features >= {"analytics", "export", "team"} + assert parsed.seats == 7 + + +def test_tampered_payload_rejected(): + key = _issue("pro") + head, body, sig = key.split(".") + swapped = body[:-2] + ("AA" if body[-2:] != "AA" else "BB") + with pytest.raises(LicenseError, match="signature"): + parse_key(".".join([head, swapped, sig])) + + +def test_expired_key_rejected_with_renewal_hint(): + key = _issue("pro", days=1) + with pytest.raises(LicenseError, match="expired"): + parse_key(key, now=time.time() + 2 * 86400) + + +def test_perpetual_key_never_expires(): + key = _issue("pro", days=0) + assert parse_key(key, now=time.time() + 3650 * 86400).plan == "pro" + + +@pytest.mark.parametrize("bad", ["", "garbage", "ENGR1.only-two", "ENGR2.x.y", + "ENGR1.!!!.???"]) +def test_malformed_keys_rejected(bad): + with pytest.raises(LicenseError): + parse_key(bad) + + +def test_unknown_plan_rejected(): + with pytest.raises(LicenseError, match="plan"): + parse_key(compose_key({"v": 1, "plan": "galactic"}, SECRET)) + + +def test_wrong_vendor_key_rejected(monkeypatch): + key = _issue("pro") + monkeypatch.setenv("ENGRAPHIS_LICENSE_PUBKEY", + ed25519_public_key(b"\x07" * 32).hex()) + with pytest.raises(LicenseError, match="signature"): + parse_key(key) + + +# ── process-level gates ──────────────────────────────────────────────────────────────── + +def test_free_tier_is_default_not_error(): + assert current_license(refresh=True) == License.free() + assert not has_feature("analytics") + assert lic.license_error() == "" + + +def test_env_key_activates_features(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", _issue("team")) + assert current_license(refresh=True).plan == "team" + assert has_feature("team") + require_feature("analytics") # must not raise + + +def test_bad_env_key_degrades_to_free_with_reason(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_LICENSE_KEY", "ENGR1.!!!.???") + assert current_license(refresh=True) == License.free() + assert lic.license_error() != "" + + +def test_require_feature_message_is_actionable(): + with pytest.raises(LicenseError, match="engraphis.dev/pro"): + require_feature("analytics") + + +def test_activate_persists_key(monkeypatch, tmp_path): + target = tmp_path / "license.key" + monkeypatch.setattr(lic, "_LICENSE_FILE", target) + key = _issue("pro") + out = lic.activate(key) + assert out.plan == "pro" + assert target.read_text().strip() == key + with pytest.raises(LicenseError): + lic.activate("ENGR1.bad.key") # invalid key: not persisted… + assert target.read_text().strip() == key # …previous key untouched