delimit-cli 4.5.10 → 4.5.12
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.
- package/gateway/ai/license.py +23 -36
- package/package.json +3 -1
- package/scripts/build-license-core.sh +85 -0
- package/scripts/test-license-core-so.sh +107 -0
- package/gateway/ai/license_core.py +0 -355
package/gateway/ai/license.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
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.
|
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.
|
|
4
|
+
"version": "4.5.12",
|
|
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",
|
|
@@ -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
|