delimit-cli 4.5.10 → 4.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,12 +26,23 @@ try:
26
26
  # Autonomous build loop
27
27
  "delimit_next_task", "delimit_task_complete",
28
28
  "delimit_loop_status", "delimit_loop_config",
29
- # LED-1253: vendor-news riff MCP wrappers
30
- "delimit_vendor_news_scan", "delimit_vendor_news_health",
31
- "delimit_vendor_news_draft",
32
29
  })
33
30
  except ImportError:
34
- # license_core not available (development mode or missing binary)
31
+ # license_core not available three known cases:
32
+ # 1. Development mode (running from gateway source, no compiled .so)
33
+ # 2. Customer on a non-Linux platform (mac/windows) — first ship is
34
+ # Linux-only; cross-platform binaries land in a follow-up.
35
+ # 3. Bundle integrity issue (.so missing or corrupt).
36
+ # Fail-closed: do not crash. Fall back to a Python-only implementation
37
+ # so the CLI keeps working; Pro features that depend on the compiled
38
+ # core may be downgraded.
39
+ import sys as _sys
40
+ print(
41
+ "delimit: license_core native module not loadable on this platform; "
42
+ "falling back to Python implementation. Pro features may be downgraded — "
43
+ "contact pro@delimit.ai if you need cross-platform Pro support.",
44
+ file=_sys.stderr,
45
+ )
35
46
  import json
36
47
  import os
37
48
  import time
@@ -82,9 +93,6 @@ except ImportError:
82
93
  # Autonomous build loop
83
94
  "delimit_next_task", "delimit_task_complete",
84
95
  "delimit_loop_status", "delimit_loop_config",
85
- # LED-1253: vendor-news riff MCP wrappers
86
- "delimit_vendor_news_scan", "delimit_vendor_news_health",
87
- "delimit_vendor_news_draft",
88
96
  })
89
97
  FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
90
98
 
@@ -122,7 +130,7 @@ except ImportError:
122
130
  import hashlib
123
131
  import urllib.request
124
132
  key = data.get("key", "")
125
- if not key or key.startswith("JAMSONS"):
133
+ if not key:
126
134
  data["last_validated_at"] = time.time()
127
135
  data["validation_status"] = "current"
128
136
  _write_license(data)
@@ -177,9 +185,6 @@ except ImportError:
177
185
  return False
178
186
  if not data.get("valid", False):
179
187
  return False
180
- key = data.get("key", "")
181
- if key.startswith("JAMSONS"):
182
- return True
183
188
  last_validated = data.get("last_validated_at", data.get("activated_at", 0))
184
189
  if last_validated == 0:
185
190
  return True
@@ -235,28 +240,10 @@ except ImportError:
235
240
  LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
236
241
  return {"status": "activated", "tier": "pro", "message": "Activated (offline fallback). Will validate on next network access."}
237
242
 
238
-
239
- # ─── LED-2060 (P1): test-mode license bypass ─────────────────────────────
240
- # tests/conftest.py sets DELIMIT_TEST_MODE=1 at session start. Without this
241
- # wrapper, every test that exercises a Pro tool got back a premium_required
242
- # error and asserted-against-the-wrong-shape, blocking CI on every PR.
243
- # Bypass is scoped: only active when the env var is explicitly set, only
244
- # returns None (the "no gate" sentinel), and wraps both compiled-binary
245
- # and fallback paths. Customers never hit this path because their
246
- # environments don't set DELIMIT_TEST_MODE.
247
- import os as _os
248
-
249
- _original_require_premium = require_premium # type: ignore[has-type]
250
- _original_is_premium = is_premium # type: ignore[has-type]
251
-
252
-
253
- def require_premium(tool_name: str): # type: ignore[no-redef]
254
- if _os.environ.get("DELIMIT_TEST_MODE") == "1":
255
- return None
256
- return _original_require_premium(tool_name)
257
-
258
-
259
- def is_premium() -> bool: # type: ignore[no-redef]
260
- if _os.environ.get("DELIMIT_TEST_MODE") == "1":
261
- return True
262
- return _original_is_premium()
243
+ # ─── LED-1254 (P0 SECURITY) ──────────────────────────────────────────────
244
+ # The DELIMIT_TEST_MODE bypass that previously lived here was removed:
245
+ # it allowed any user who grepped the installed source to set
246
+ # DELIMIT_TEST_MODE=1 and unconditionally bypass every Pro license check.
247
+ # Test-time bypass is now provided exclusively by tests/conftest.py via
248
+ # monkeypatch on require_premium / is_premium. The shipped library no
249
+ # longer reads any test-mode env var.
@@ -1,42 +1,38 @@
1
1
  # This file was generated by Nuitka
2
2
 
3
3
  # Stubs included by default
4
- from __future__ import annotations
4
+ import time
5
+ import json
6
+ import os
5
7
  from pathlib import Path
6
8
  import hashlib
7
- import json
8
- import time
9
9
 
10
- LICENSE_FILE = Path.home() / '.delimit' / 'license.json'
11
- USAGE_FILE = Path.home() / '.delimit' / 'usage.json'
12
- LS_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate'
13
- REVALIDATION_INTERVAL = 30 * 86400
14
- GRACE_PERIOD = 7 * 86400
15
- HARD_BLOCK = 14 * 86400
16
- PRO_TOOLS = frozenset({'delimit_gov_health', 'delimit_gov_status', 'delimit_gov_evaluate', 'delimit_gov_policy', 'delimit_gov_run', 'delimit_gov_verify', 'delimit_deploy_plan', 'delimit_deploy_build', 'delimit_deploy_publish', 'delimit_deploy_verify', 'delimit_deploy_rollback', 'delimit_deploy_site', 'delimit_deploy_npm', 'delimit_memory_store', 'delimit_memory_search', 'delimit_memory_recent', 'delimit_vault_search', 'delimit_vault_snapshot', 'delimit_vault_health', 'delimit_evidence_collect', 'delimit_evidence_verify', 'delimit_deliberate', 'delimit_models', 'delimit_obs_metrics', 'delimit_obs_logs', 'delimit_obs_status', 'delimit_release_plan', 'delimit_release_status', 'delimit_release_sync', 'delimit_cost_analyze', 'delimit_cost_optimize', 'delimit_cost_alert'})
17
- FREE_TRIAL_LIMITS = {'delimit_deliberate': 3}
10
+ PRO_TOOLS = frozenset({'delimit_gov_evaluate', 'delimit_gov_policy', 'delimit_gov_run', 'delimit_gov_verify', 'delimit_os_plan', 'delimit_os_status', 'delimit_os_gates', 'delimit_deploy_plan', 'delimit_deploy_build', 'delimit_deploy_publish', 'delimit_deploy_verify', 'delimit_deploy_rollback', 'delimit_deploy_status', 'delimit_deploy_site', 'delimit_deploy_npm', 'delimit_memory_search', 'delimit_vault_search', 'delimit_vault_snapshot', 'delimit_vault_health', 'delimit_evidence_collect', 'delimit_evidence_verify', 'delimit_deliberate', 'delimit_models', 'delimit_obs_metrics', 'delimit_obs_logs', 'delimit_obs_status', 'delimit_release_plan', 'delimit_release_status', 'delimit_release_sync', 'delimit_cost_analyze', 'delimit_cost_optimize', 'delimit_cost_alert', 'delimit_social_post', 'delimit_social_generate', 'delimit_social_history', 'delimit_screen_record', 'delimit_screenshot', 'delimit_notify', 'delimit_agent_dispatch', 'delimit_agent_status', 'delimit_agent_complete', 'delimit_agent_handoff', 'delimit_executor'})
11
+ def needs_revalidation(data: dict) -> bool:
12
+ ...
13
+ def revalidate_license(data: dict) -> dict:
14
+ ...
15
+ def is_license_valid(data: dict) -> bool:
16
+ ...
17
+ def _write_license(data: dict) -> None:
18
+ ...
19
+ def _call_lemon_squeezy(data: dict) -> bool | None:
20
+ ...
18
21
  def load_license() -> dict:
19
22
  ...
20
-
21
23
  def check_premium() -> bool:
22
24
  ...
23
-
24
25
  def gate_tool(tool_name: str) -> dict | None:
25
26
  ...
26
-
27
27
  def activate(key: str) -> dict:
28
28
  ...
29
-
30
29
  def _revalidate(data: dict) -> dict:
31
30
  ...
32
-
33
31
  def _get_monthly_usage(tool_name: str) -> int:
34
32
  ...
35
-
36
33
  def _increment_usage(tool_name: str) -> int:
37
34
  ...
38
35
 
39
-
40
36
  __name__ = ...
41
37
 
42
38
 
@@ -44,7 +40,9 @@ __name__ = ...
44
40
  # Modules used internally, to allow implicit dependencies to be seen:
45
41
  import hashlib
46
42
  import json
43
+ import os
47
44
  import time
48
45
  import pathlib
49
46
  import urllib
50
- import urllib.request
47
+ import urllib.request
48
+ import re
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.5.10",
4
+ "version": "4.5.13",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -32,6 +32,8 @@
32
32
  "!gateway/ai/content_grounding/",
33
33
  "!gateway/ai/inbox_drafts/",
34
34
  "!gateway/ai/inbox_executor.py",
35
+ "!gateway/ai/license_core.py",
36
+ "gateway/ai/license_core.cpython-*-*.so",
35
37
  "scripts/",
36
38
  "!scripts/crosspost_devto.py",
37
39
  "!scripts/repo_targeting.py",
@@ -53,7 +55,7 @@
53
55
  "scripts": {
54
56
  "postinstall": "node scripts/postinstall.js",
55
57
  "sync-gateway": "bash scripts/sync-gateway.sh",
56
- "prepublishOnly": "bash scripts/publish-ci-guard.sh && npm run sync-gateway && bash scripts/security-check.sh",
58
+ "prepublishOnly": "bash scripts/publish-ci-guard.sh && npm run sync-gateway && bash scripts/build-license-core.sh && bash scripts/security-check.sh",
57
59
  "test": "node --test tests/setup-onboarding.test.js tests/setup-matrix.test.js tests/setup-no-clobber.test.js tests/config-export-import.test.js tests/cross-model-hooks.test.js tests/golden-path.test.js tests/v420-features.test.js tests/v43-wrap-engine.test.js tests/v43-trust-page-engine.test.js tests/v43-ai-sbom-engine.test.js tests/attest-mcp.test.js tests/delimit-home.test.js tests/postinstall-hardening.test.js"
58
60
  },
59
61
  "keywords": [
@@ -0,0 +1,85 @@
1
+ #!/bin/bash
2
+ # LED-1259: Compile gateway/ai/license_core.py to a native .so via Nuitka,
3
+ # then strip the plaintext .py from the bundle so customers cannot grep
4
+ # the validation logic for bypass identifiers.
5
+ #
6
+ # Linux-only first ship. Mac/Windows expansion is filed as a follow-up
7
+ # ledger item — non-linux customers will hit the Python fallback in
8
+ # license.py (degraded Pro features) until we ship per-platform binaries.
9
+ #
10
+ # Idempotent: safe to re-run; will rebuild on every invocation.
11
+
12
+ set -euo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ NPM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
16
+ AI_DIR="$NPM_ROOT/gateway/ai"
17
+ SRC="$AI_DIR/license_core.py"
18
+
19
+ # ── Platform gate ────────────────────────────────────────────────────
20
+ UNAME_S="$(uname -s)"
21
+ UNAME_M="$(uname -m)"
22
+ if [ "$UNAME_S" != "Linux" ]; then
23
+ echo "⚠️ build-license-core: non-Linux host ($UNAME_S) — skipping compile."
24
+ echo " First ship is linux-only. The bundle will fall back to .py."
25
+ exit 0
26
+ fi
27
+
28
+ if [ ! -f "$SRC" ]; then
29
+ echo "❌ Source not found: $SRC"
30
+ exit 1
31
+ fi
32
+
33
+ # ── Toolchain check ──────────────────────────────────────────────────
34
+ PY="${PYTHON:-python3}"
35
+ if ! command -v "$PY" >/dev/null 2>&1; then
36
+ echo "❌ python3 not found"
37
+ exit 1
38
+ fi
39
+
40
+ PY_VER="$($PY -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
41
+ echo "🔧 build-license-core: python=$PY ($PY_VER), arch=$UNAME_M"
42
+
43
+ if ! "$PY" -m nuitka --version >/dev/null 2>&1; then
44
+ echo "📦 nuitka not installed — installing via pip..."
45
+ "$PY" -m pip install --quiet --user nuitka
46
+ fi
47
+
48
+ NUITKA_VER="$($PY -m nuitka --version 2>&1 | head -1)"
49
+ echo " nuitka=$NUITKA_VER"
50
+
51
+ # ── Compile ──────────────────────────────────────────────────────────
52
+ echo "🔨 Compiling license_core.py → .so (this takes ~30s)..."
53
+ cd "$AI_DIR"
54
+ "$PY" -m nuitka --module --quiet --remove-output --output-dir=. license_core.py
55
+
56
+ # ── Verify output ────────────────────────────────────────────────────
57
+ SO_FILE="$(ls -1 license_core.cpython-*-*.so 2>/dev/null | head -1 || true)"
58
+ if [ -z "$SO_FILE" ] || [ ! -f "$SO_FILE" ]; then
59
+ echo "❌ Compile failed — no .so produced in $AI_DIR"
60
+ ls -la "$AI_DIR"/license_core* 2>&1 || true
61
+ exit 1
62
+ fi
63
+
64
+ SO_SIZE="$(stat -c%s "$SO_FILE")"
65
+ echo " ✅ produced: $SO_FILE ($SO_SIZE bytes)"
66
+
67
+ # ── Bypass-identifier scan ───────────────────────────────────────────
68
+ # Customers must not be able to `strings | grep` the .so for known
69
+ # bypass class names. Fail the build if any leak through.
70
+ BYPASS_HITS="$(strings "$SO_FILE" | grep -iE 'DELIMIT_TEST_MODE|DELIMIT_INTERNAL_LICENSE_KEY|JAMSONS' || true)"
71
+ if [ -n "$BYPASS_HITS" ]; then
72
+ echo "❌ Bypass identifiers found in compiled .so:"
73
+ echo "$BYPASS_HITS"
74
+ exit 1
75
+ fi
76
+ echo " ✅ strings-grep clean (no bypass identifiers)"
77
+
78
+ # ── Drop the plaintext source from the bundle ────────────────────────
79
+ # .npmignore + package.json will also exclude it, but removing here is
80
+ # belt-and-suspenders so dev/test inspection of the bundle dir matches
81
+ # what gets packed.
82
+ rm -f "$AI_DIR/license_core.py"
83
+ echo " ✅ removed plaintext license_core.py from bundle"
84
+
85
+ echo "✅ build-license-core complete: $SO_FILE"
@@ -0,0 +1,107 @@
1
+ #!/bin/bash
2
+ # LED-1259: Verify that the compiled license_core .so:
3
+ # 1. Compiles cleanly from gateway/ai/license_core.py
4
+ # 2. Imports successfully when the .py is absent
5
+ # 3. Exposes all public functions/constants the shim relies on
6
+ # 4. Returns correct validation verdicts for known-valid / known-expired
7
+ # license dicts
8
+ # 5. Contains zero bypass identifiers in `strings` output
9
+ #
10
+ # Runs in an isolated tmp dir so it doesn't pollute the bundle layout.
11
+
12
+ set -euo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ NPM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
16
+ SRC="$NPM_ROOT/gateway/ai/license_core.py"
17
+
18
+ if [ "$(uname -s)" != "Linux" ]; then
19
+ echo "⏭️ test-license-core-so: non-Linux host — skipping (Linux-only first ship)"
20
+ exit 0
21
+ fi
22
+
23
+ if [ ! -f "$SRC" ]; then
24
+ echo "ℹ️ license_core.py not in bundle (already compiled or pre-build) — copying from gateway src for test"
25
+ GW_SRC="${GATEWAY_OVERRIDE:-/home/delimit/delimit-gateway}/ai/license_core.py"
26
+ if [ ! -f "$GW_SRC" ]; then
27
+ echo "❌ No license_core.py source found (bundle or gateway). Cannot test."
28
+ exit 1
29
+ fi
30
+ SRC="$GW_SRC"
31
+ fi
32
+
33
+ PY="${PYTHON:-python3}"
34
+ TMP="$(mktemp -d)"
35
+ trap 'rm -rf "$TMP"' EXIT
36
+
37
+ echo "🧪 test-license-core-so: building in $TMP"
38
+ mkdir -p "$TMP/ai"
39
+ touch "$TMP/ai/__init__.py"
40
+ cp "$SRC" "$TMP/license_core.py"
41
+
42
+ cd "$TMP"
43
+ "$PY" -m nuitka --module --quiet --remove-output --output-dir=. license_core.py 2>&1 | tail -3
44
+
45
+ SO_FILE="$(ls -1 license_core.cpython-*-*.so 2>/dev/null | head -1)"
46
+ if [ -z "$SO_FILE" ]; then
47
+ echo "❌ Compile failed — no .so produced"
48
+ exit 1
49
+ fi
50
+ echo " ✅ compiled: $SO_FILE ($(stat -c%s "$SO_FILE") bytes)"
51
+
52
+ # Strings-grep for bypass identifiers
53
+ HITS="$(strings "$SO_FILE" | grep -iE 'DELIMIT_TEST_MODE|DELIMIT_INTERNAL_LICENSE_KEY|JAMSONS' || true)"
54
+ if [ -n "$HITS" ]; then
55
+ echo "❌ Bypass identifiers leaked into .so:"
56
+ echo "$HITS"
57
+ exit 1
58
+ fi
59
+ echo " ✅ strings-grep clean"
60
+
61
+ # Move .so under ai/, drop the .py, run import + behaviour checks
62
+ mv "$SO_FILE" "ai/$SO_FILE"
63
+ rm -f license_core.py
64
+
65
+ "$PY" - <<'PY'
66
+ import os, sys, time
67
+ sys.path.insert(0, ".")
68
+
69
+ # Import via the compiled .so only — no .py present
70
+ from ai.license_core import (
71
+ is_license_valid, revalidate_license, needs_revalidation,
72
+ PRO_TOOLS, LICENSE_FILE, FREE_TRIAL_LIMITS, activate,
73
+ load_license, check_premium, gate_tool,
74
+ )
75
+
76
+ assert isinstance(PRO_TOOLS, frozenset) and len(PRO_TOOLS) > 0, "PRO_TOOLS must be a non-empty frozenset"
77
+ assert FREE_TRIAL_LIMITS.get("delimit_deliberate") == 3, "FREE_TRIAL_LIMITS missing delimit_deliberate=3"
78
+ assert "delimit_deliberate" in PRO_TOOLS, "PRO_TOOLS must include delimit_deliberate"
79
+ assert callable(activate), "activate must be callable"
80
+ assert callable(revalidate_license), "revalidate_license must be callable"
81
+
82
+ # Known-valid: pro tier, recent last_validated_at
83
+ valid_recent = {"tier": "pro", "valid": True, "last_validated_at": time.time()}
84
+ assert is_license_valid(valid_recent) is True, "recent pro license should be valid"
85
+ assert needs_revalidation(valid_recent) is False, "recent pro license should not need revalidation"
86
+
87
+ # Known-invalid: pro tier but last_validated_at > 44 days ago (beyond hard cutoff)
88
+ expired = {"tier": "pro", "valid": True, "last_validated_at": time.time() - 60 * 86400}
89
+ assert is_license_valid(expired) is False, "expired (60d) pro license must be invalid"
90
+ assert needs_revalidation(expired) is True, "expired (60d) pro license must need revalidation"
91
+
92
+ # Free tier never valid for Pro
93
+ free = {"tier": "free", "valid": True}
94
+ assert is_license_valid(free) is False, "free tier must not pass is_license_valid"
95
+
96
+ # valid=False forces invalid even when timestamp is recent
97
+ revoked = {"tier": "pro", "valid": False, "last_validated_at": time.time()}
98
+ assert is_license_valid(revoked) is False, "revoked license must be invalid"
99
+
100
+ # Legacy file with no timestamps — should signal needs_revalidation=True
101
+ legacy = {"tier": "pro", "valid": True}
102
+ assert needs_revalidation(legacy) is True, "legacy license without timestamps must need revalidation"
103
+
104
+ print("ALL_OK")
105
+ PY
106
+
107
+ echo "✅ test-license-core-so: all checks passed"
@@ -1,355 +0,0 @@
1
- """
2
- Delimit license enforcement core — compiled with Nuitka.
3
- Contains: validation logic, re-validation, usage tracking, entitlement checks.
4
- This module is distributed as a native binary (.so/.pyd), not readable Python.
5
- """
6
- import hashlib
7
- import json
8
- import time
9
- from pathlib import Path
10
-
11
- LICENSE_FILE = Path.home() / ".delimit" / "license.json"
12
- USAGE_FILE = Path.home() / ".delimit" / "usage.json"
13
- LS_VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate"
14
-
15
- REVALIDATION_INTERVAL = 30 * 86400 # 30 days
16
- GRACE_PERIOD = 7 * 86400
17
- HARD_BLOCK = 14 * 86400
18
-
19
- # Pro tools that require a license
20
- PRO_TOOLS = frozenset({
21
- "delimit_gov_evaluate",
22
- "delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
23
- "delimit_os_plan", "delimit_os_status", "delimit_os_gates",
24
- "delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
25
- "delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
26
- "delimit_deploy_site", "delimit_deploy_npm",
27
- # delimit_memory_store + delimit_memory_recent are FREE (LED-193 — basic
28
- # store + recent retrieval). Only delimit_memory_search is Pro.
29
- "delimit_memory_search",
30
- "delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
31
- "delimit_evidence_collect", "delimit_evidence_verify",
32
- "delimit_deliberate", "delimit_models",
33
- "delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
34
- "delimit_release_plan", "delimit_release_status", "delimit_release_sync",
35
- "delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
36
- "delimit_social_post", "delimit_social_generate", "delimit_social_history",
37
- "delimit_screen_record", "delimit_screenshot",
38
- "delimit_notify",
39
- # Agent orchestration
40
- "delimit_agent_dispatch", "delimit_agent_status",
41
- "delimit_agent_complete", "delimit_agent_handoff",
42
- # Worker Pool v2 executor (LED-981)
43
- "delimit_executor",
44
- })
45
-
46
- # Free trial limits
47
- FREE_TRIAL_LIMITS = {
48
- "delimit_deliberate": 3,
49
- }
50
-
51
-
52
- def needs_revalidation(data: dict) -> bool:
53
- """Check if a license needs re-validation (30+ days since last check).
54
-
55
- Args:
56
- data: License data dict (from license.json).
57
-
58
- Returns:
59
- True if 30+ days have elapsed since last_validated_at (or activated_at
60
- as fallback). Also returns True if neither timestamp exists (legacy
61
- license.json files without last_validated_at).
62
- """
63
- if data.get("tier") not in ("pro", "enterprise"):
64
- return False
65
- last_validated = data.get("last_validated_at", data.get("activated_at", 0))
66
- if last_validated == 0:
67
- return True # Legacy file — treat as needing validation
68
- return (time.time() - last_validated) > REVALIDATION_INTERVAL
69
-
70
-
71
- def revalidate_license(data: dict) -> dict:
72
- """Re-validate a license against Lemon Squeezy.
73
-
74
- Privacy-preserving: only sends license_key and instance_name (machine hash).
75
- Non-blocking: network failures return offline grace status, never crash.
76
-
77
- Args:
78
- data: License data dict (must contain 'key').
79
-
80
- Returns:
81
- Dict with 'status' key:
82
- - "valid": API confirmed license is active, last_validated_at updated
83
- - "grace": API unreachable or returned invalid, but within grace period
84
- - "expired": beyond grace + hard block cutoff, Pro tools should be blocked
85
- Also includes 'updated_data' with the (possibly modified) license data.
86
- """
87
- key = data.get("key", "")
88
- # Internal/founder keys always pass
89
- if not key or key.startswith("JAMSONS"):
90
- data["last_validated_at"] = time.time()
91
- data["validation_status"] = "current"
92
- _write_license(data)
93
- return {"status": "valid", "updated_data": data}
94
-
95
- last_validated = data.get("last_validated_at", data.get("activated_at", 0))
96
- elapsed = time.time() - last_validated
97
-
98
- # Try API call
99
- api_valid = _call_lemon_squeezy(data)
100
-
101
- if api_valid is True:
102
- data["last_validated_at"] = time.time()
103
- data["validation_status"] = "current"
104
- data.pop("grace_days_remaining", None)
105
- _write_license(data)
106
- return {"status": "valid", "updated_data": data}
107
-
108
- # API said invalid or was unreachable — check grace/expiry windows
109
- if elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
110
- data["validation_status"] = "expired"
111
- data["valid"] = False
112
- _write_license(data)
113
- return {
114
- "status": "expired",
115
- "updated_data": data,
116
- "reason": "License expired — no successful re-validation in 44 days. Renew at https://delimit.ai/pricing",
117
- }
118
-
119
- if elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
120
- days_left = max(0, int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400))
121
- data["validation_status"] = "grace_period"
122
- data["grace_days_remaining"] = days_left
123
- _write_license(data)
124
- return {
125
- "status": "grace",
126
- "updated_data": data,
127
- "grace_days_remaining": days_left,
128
- "message": f"License re-validation failed. {days_left} days until Pro features are disabled.",
129
- }
130
-
131
- # Within first 7 days after revalidation interval — soft pending
132
- data["validation_status"] = "revalidation_pending"
133
- _write_license(data)
134
- return {"status": "grace", "updated_data": data}
135
-
136
-
137
- def is_license_valid(data: dict) -> bool:
138
- """Check if a license is currently valid for Pro tool access.
139
-
140
- Returns True if:
141
- - last_validated_at is within 30 days (current), OR
142
- - last_validated_at is within 37 days (30 + 7 grace), OR
143
- - last_validated_at is within 44 days (30 + 14 hard cutoff)
144
- Returns False if beyond 44 days with no successful re-validation.
145
-
146
- Backwards compatible: missing last_validated_at falls back to activated_at,
147
- and missing both returns False (triggers re-validation).
148
- """
149
- if data.get("tier") not in ("pro", "enterprise"):
150
- return False
151
- if not data.get("valid", False):
152
- return False
153
- # Internal/founder keys always valid
154
- key = data.get("key", "")
155
- if key.startswith("JAMSONS"):
156
- return True
157
- last_validated = data.get("last_validated_at", data.get("activated_at", 0))
158
- if last_validated == 0:
159
- return True # Legacy — allow access but needs_revalidation will trigger check
160
- elapsed = time.time() - last_validated
161
- return elapsed <= (REVALIDATION_INTERVAL + HARD_BLOCK)
162
-
163
-
164
- def _write_license(data: dict) -> None:
165
- """Persist license data to disk."""
166
- try:
167
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
168
- LICENSE_FILE.write_text(json.dumps(data, indent=2))
169
- except Exception:
170
- pass # Non-blocking — don't crash on disk errors
171
-
172
-
173
- def _call_lemon_squeezy(data: dict) -> bool | None:
174
- """Call Lemon Squeezy validation API. Privacy-preserving.
175
-
176
- Only sends license_key and instance_name (machine hash).
177
-
178
- Returns:
179
- True if valid, False if invalid, None if unreachable.
180
- """
181
- key = data.get("key", "")
182
- machine_hash = data.get("machine_hash", hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16])
183
- try:
184
- import urllib.request
185
- req_data = json.dumps({
186
- "license_key": key,
187
- "instance_name": machine_hash,
188
- }).encode()
189
- req = urllib.request.Request(
190
- LS_VALIDATE_URL, data=req_data,
191
- headers={"Content-Type": "application/json", "Accept": "application/json"},
192
- method="POST",
193
- )
194
- with urllib.request.urlopen(req, timeout=10) as resp:
195
- result = json.loads(resp.read())
196
- return result.get("valid", False)
197
- except Exception:
198
- return None # Unreachable — caller should use grace period
199
-
200
-
201
- def load_license() -> dict:
202
- """Load and validate license with periodic re-validation.
203
-
204
- Re-validates against Lemon Squeezy every 30 days. On failure, provides
205
- a 7-day grace period followed by a 7-day warning period. After 44 days
206
- without successful re-validation, Pro tools are blocked.
207
- """
208
- if not LICENSE_FILE.exists():
209
- return {"tier": "free", "valid": True}
210
- try:
211
- data = json.loads(LICENSE_FILE.read_text())
212
- if data.get("expires_at") and data["expires_at"] < time.time():
213
- return {"tier": "free", "valid": True, "expired": True}
214
-
215
- if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
216
- if needs_revalidation(data):
217
- result = revalidate_license(data)
218
- data = result["updated_data"]
219
- if result["status"] == "expired":
220
- return {"tier": "free", "valid": True, "revoked": True,
221
- "reason": result.get("reason", "License expired. Renew at https://delimit.ai/pricing")}
222
- return data
223
- except Exception:
224
- return {"tier": "free", "valid": True}
225
-
226
-
227
- def check_premium() -> bool:
228
- """Check if user has a valid premium license.
229
-
230
- Uses load_license() which triggers re-validation if needed, then
231
- checks is_license_valid() on the result.
232
- """
233
- lic = load_license()
234
- return is_license_valid(lic)
235
-
236
-
237
- def gate_tool(tool_name: str) -> dict | None:
238
- """Gate a Pro tool. Returns None if allowed, error dict if blocked."""
239
- # Normalize: accept both "os_plan" and "delimit_os_plan"
240
- full_name = tool_name if tool_name.startswith("delimit_") else f"delimit_{tool_name}"
241
- if full_name not in PRO_TOOLS:
242
- return None
243
- if check_premium():
244
- return None
245
-
246
- # Check free trial
247
- limit = FREE_TRIAL_LIMITS.get(tool_name)
248
- if limit is not None:
249
- used = _get_monthly_usage(tool_name)
250
- if used < limit:
251
- _increment_usage(tool_name)
252
- return None
253
- return {
254
- "error": f"Free trial limit reached ({limit}/month). Upgrade to Pro for unlimited.",
255
- "status": "trial_exhausted",
256
- "tool": tool_name,
257
- "used": used,
258
- "limit": limit,
259
- "upgrade_url": "https://delimit.ai/pricing",
260
- }
261
-
262
- return {
263
- "error": f"'{tool_name}' requires Delimit Pro ($10/mo). Upgrade at https://delimit.ai/pricing",
264
- "status": "premium_required",
265
- "tool": tool_name,
266
- "current_tier": load_license().get("tier", "free"),
267
- }
268
-
269
-
270
- def activate(key: str) -> dict:
271
- """Activate a license key."""
272
- import re
273
- if not key or len(key) < 10:
274
- return {"error": "Invalid license key format"}
275
- # Accept DELIMIT-XXXX-XXXX-XXXX pattern or Lemon Squeezy format
276
- if key.startswith("DELIMIT-") and not re.match(r"^DELIMIT-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", key):
277
- return {"error": "Invalid key format. Expected: DELIMIT-XXXX-XXXX-XXXX"}
278
-
279
- machine_hash = hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16]
280
-
281
- try:
282
- import urllib.request
283
- data = json.dumps({"license_key": key, "instance_name": machine_hash}).encode()
284
- req = urllib.request.Request(
285
- LS_VALIDATE_URL, data=data,
286
- headers={"Content-Type": "application/json", "Accept": "application/json"},
287
- method="POST",
288
- )
289
- with urllib.request.urlopen(req, timeout=10) as resp:
290
- result = json.loads(resp.read())
291
-
292
- if result.get("valid"):
293
- license_data = {
294
- "key": key, "tier": "pro", "valid": True,
295
- "activated_at": time.time(), "last_validated_at": time.time(),
296
- "machine_hash": machine_hash,
297
- "instance_id": result.get("instance", {}).get("id"),
298
- "validated_via": "lemon_squeezy",
299
- }
300
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
301
- LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
302
- return {"status": "activated", "tier": "pro"}
303
- return {"error": "Invalid license key.", "status": "invalid"}
304
-
305
- except Exception:
306
- license_data = {
307
- "key": key, "tier": "pro", "valid": True,
308
- "activated_at": time.time(), "last_validated_at": time.time(),
309
- "machine_hash": machine_hash, "validated_via": "offline",
310
- }
311
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
312
- LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
313
- return {"status": "activated", "tier": "pro", "message": "Activated offline."}
314
-
315
-
316
- def _revalidate(data: dict) -> dict:
317
- """Re-validate against Lemon Squeezy (legacy wrapper).
318
-
319
- Deprecated: use revalidate_license() for the full status/grace workflow.
320
- Kept for backwards compatibility with any external callers.
321
- """
322
- result = _call_lemon_squeezy(data)
323
- if result is True:
324
- return {"valid": True}
325
- if result is False:
326
- return {"valid": False}
327
- # None = unreachable — grant offline grace
328
- return {"valid": True, "offline": True}
329
-
330
-
331
- def _get_monthly_usage(tool_name: str) -> int:
332
- if not USAGE_FILE.exists():
333
- return 0
334
- try:
335
- data = json.loads(USAGE_FILE.read_text())
336
- return data.get(time.strftime("%Y-%m"), {}).get(tool_name, 0)
337
- except Exception:
338
- return 0
339
-
340
-
341
- def _increment_usage(tool_name: str) -> int:
342
- month_key = time.strftime("%Y-%m")
343
- data = {}
344
- if USAGE_FILE.exists():
345
- try:
346
- data = json.loads(USAGE_FILE.read_text())
347
- except Exception:
348
- pass
349
- if month_key not in data:
350
- data[month_key] = {}
351
- data[month_key][tool_name] = data[month_key].get(tool_name, 0) + 1
352
- count = data[month_key][tool_name]
353
- USAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
354
- USAGE_FILE.write_text(json.dumps(data, indent=2))
355
- return count