syntaxmatrix 2.6.4.4__py3-none-any.whl → 3.0.1__py3-none-any.whl
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.
- syntaxmatrix/__init__.py +6 -4
- syntaxmatrix/agentic/agents.py +206 -26
- syntaxmatrix/agentic/agents_orchestrer.py +16 -10
- syntaxmatrix/client_docs.py +237 -0
- syntaxmatrix/commentary.py +96 -25
- syntaxmatrix/core.py +142 -56
- syntaxmatrix/dataset_preprocessing.py +2 -2
- syntaxmatrix/db.py +0 -17
- syntaxmatrix/kernel_manager.py +174 -150
- syntaxmatrix/page_builder_generation.py +656 -63
- syntaxmatrix/page_layout_contract.py +25 -3
- syntaxmatrix/page_patch_publish.py +368 -15
- syntaxmatrix/plugins/__init__.py +0 -0
- syntaxmatrix/premium/__init__.py +10 -2
- syntaxmatrix/premium/catalogue/__init__.py +121 -0
- syntaxmatrix/premium/gate.py +15 -3
- syntaxmatrix/premium/state.py +507 -0
- syntaxmatrix/premium/verify.py +222 -0
- syntaxmatrix/profiles.py +1 -1
- syntaxmatrix/routes.py +9847 -8004
- syntaxmatrix/settings/model_map.py +50 -65
- syntaxmatrix/settings/prompts.py +1186 -414
- syntaxmatrix/settings/string_navbar.py +4 -4
- syntaxmatrix/static/icons/bot_icon.png +0 -0
- syntaxmatrix/static/icons/bot_icon2.png +0 -0
- syntaxmatrix/templates/admin_billing.html +408 -0
- syntaxmatrix/templates/admin_branding.html +65 -2
- syntaxmatrix/templates/admin_features.html +54 -0
- syntaxmatrix/templates/dashboard.html +285 -8
- syntaxmatrix/templates/edit_page.html +199 -18
- syntaxmatrix/themes.py +17 -17
- syntaxmatrix/workspace_db.py +0 -23
- syntaxmatrix-3.0.1.dist-info/METADATA +219 -0
- {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.dist-info}/RECORD +38 -33
- {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.dist-info}/WHEEL +1 -1
- syntaxmatrix/settings/default.yaml +0 -13
- syntaxmatrix-2.6.4.4.dist-info/METADATA +0 -539
- syntaxmatrix-2.6.4.4.dist-info/licenses/LICENSE.txt +0 -21
- /syntaxmatrix/{plugin_manager.py → plugins/plugin_manager.py} +0 -0
- /syntaxmatrix/static/icons/{logo3.png → logo2.png} +0 -0
- {syntaxmatrix-2.6.4.4.dist-info → syntaxmatrix-3.0.1.dist-info}/top_level.txt +0 -0
syntaxmatrix/premium/gate.py
CHANGED
|
@@ -47,12 +47,24 @@ class FeatureGate:
|
|
|
47
47
|
self._sources = sources or GateSources()
|
|
48
48
|
self._cache: Optional[Dict[str, Any]] = None
|
|
49
49
|
|
|
50
|
+
def _normalise(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return None
|
|
53
|
+
if isinstance(data.get("entitlements"), dict):
|
|
54
|
+
ent = dict(data.get("entitlements") or {})
|
|
55
|
+
if data.get("plan") and not ent.get("plan"):
|
|
56
|
+
ent["plan"] = data.get("plan")
|
|
57
|
+
return ent
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
50
61
|
def _load_from_env(self) -> Optional[Dict[str, Any]]:
|
|
51
62
|
raw = os.environ.get(self._sources.env_json)
|
|
52
63
|
if not raw:
|
|
53
64
|
return None
|
|
54
65
|
data = _safe_json_loads(raw, default=None)
|
|
55
|
-
return
|
|
66
|
+
return self._normalise(data)
|
|
67
|
+
|
|
56
68
|
|
|
57
69
|
def _load_from_db(self) -> Optional[Dict[str, Any]]:
|
|
58
70
|
if not self._db:
|
|
@@ -63,7 +75,7 @@ class FeatureGate:
|
|
|
63
75
|
|
|
64
76
|
raw = get_setting(self._sources.db_setting_key, "{}")
|
|
65
77
|
data = _safe_json_loads(str(raw or "{}"), default={})
|
|
66
|
-
return
|
|
78
|
+
return self._normalise(data)
|
|
67
79
|
|
|
68
80
|
def _load_from_file(self) -> Optional[Dict[str, Any]]:
|
|
69
81
|
p = os.path.join(self._client_dir, self._sources.licence_file_relpath)
|
|
@@ -72,7 +84,7 @@ class FeatureGate:
|
|
|
72
84
|
try:
|
|
73
85
|
with open(p, "r", encoding="utf-8") as f:
|
|
74
86
|
data = json.load(f)
|
|
75
|
-
return
|
|
87
|
+
return self._normalise(data)
|
|
76
88
|
except Exception:
|
|
77
89
|
return None
|
|
78
90
|
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from .verify import instance_fingerprint, verify_licence_payload
|
|
10
|
+
from .catalogue import resolve_entitlements
|
|
11
|
+
from syntaxmatrix.project_root import detect_project_root
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _utcnow() -> datetime:
|
|
15
|
+
return datetime.utcnow()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _iso_utc(dt: datetime) -> str:
|
|
19
|
+
return dt.replace(microsecond=0).isoformat() + "Z"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_iso_utc(s: str) -> Optional[datetime]:
|
|
23
|
+
if not s:
|
|
24
|
+
return None
|
|
25
|
+
s = str(s).strip()
|
|
26
|
+
if not s:
|
|
27
|
+
return None
|
|
28
|
+
s2 = s.replace(" ", "T")
|
|
29
|
+
if s2.endswith("Z"):
|
|
30
|
+
s2 = s2[:-1]
|
|
31
|
+
try:
|
|
32
|
+
return datetime.fromisoformat(s2)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_json_loads(raw: str, default: Any) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(raw)
|
|
40
|
+
except Exception:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _truthy(v: Any) -> bool:
|
|
45
|
+
return str(v).strip().lower() in ("1", "true", "yes", "y", "on")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PremiumState:
|
|
50
|
+
trial_days: int
|
|
51
|
+
trial_started_at: Optional[str]
|
|
52
|
+
trial_active: bool
|
|
53
|
+
trial_days_left: int
|
|
54
|
+
plan: str
|
|
55
|
+
entitlements: Dict[str, Any]
|
|
56
|
+
source: str # trial | env | file | db | none
|
|
57
|
+
instance_id: Optional[str] = None
|
|
58
|
+
remote_status: Optional[str] = None
|
|
59
|
+
remote_grace_until: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fallback_entitlements(plan: str) -> Dict[str, Any]:
|
|
63
|
+
"""Hard-coded fallbacks (only used if catalogue files are missing).
|
|
64
|
+
|
|
65
|
+
Normal path: entitlements are catalogue-driven so you can add/update values
|
|
66
|
+
without touching Python.
|
|
67
|
+
"""
|
|
68
|
+
p = (plan or "").strip().lower()
|
|
69
|
+
if p == "pro":
|
|
70
|
+
return {
|
|
71
|
+
"plan": "pro",
|
|
72
|
+
"docs": True,
|
|
73
|
+
"ml_lab": True,
|
|
74
|
+
"registration": True,
|
|
75
|
+
"theme_toggle": True,
|
|
76
|
+
"branding_controls": True,
|
|
77
|
+
"plugins": True,
|
|
78
|
+
"premium_db_backends": False,
|
|
79
|
+
"audit_export": True,
|
|
80
|
+
"max_users": 10,
|
|
81
|
+
"max_pages": 50,
|
|
82
|
+
"max_upload_mb": 200,
|
|
83
|
+
"max_pdf_pages_per_doc": 300,
|
|
84
|
+
"max_vector_records": 50000,
|
|
85
|
+
}
|
|
86
|
+
if p == "business":
|
|
87
|
+
return {
|
|
88
|
+
"plan": "business",
|
|
89
|
+
"docs": True,
|
|
90
|
+
"ml_lab": True,
|
|
91
|
+
"registration": True,
|
|
92
|
+
"theme_toggle": True,
|
|
93
|
+
"branding_controls": True,
|
|
94
|
+
"plugins": True,
|
|
95
|
+
"premium_db_backends": True,
|
|
96
|
+
"audit_export": True,
|
|
97
|
+
"max_users": 25,
|
|
98
|
+
"max_pages": 150,
|
|
99
|
+
"max_upload_mb": 750,
|
|
100
|
+
"max_pdf_pages_per_doc": 750,
|
|
101
|
+
"max_vector_records": 200000,
|
|
102
|
+
}
|
|
103
|
+
if p == "enterprise":
|
|
104
|
+
return {
|
|
105
|
+
"plan": "enterprise",
|
|
106
|
+
"docs": True,
|
|
107
|
+
"ml_lab": True,
|
|
108
|
+
"registration": True,
|
|
109
|
+
"theme_toggle": True,
|
|
110
|
+
"branding_controls": True,
|
|
111
|
+
"plugins": True,
|
|
112
|
+
"premium_db_backends": True,
|
|
113
|
+
"audit_export": True,
|
|
114
|
+
"max_users": 200,
|
|
115
|
+
"max_pages": 500,
|
|
116
|
+
"max_upload_mb": 2000,
|
|
117
|
+
"max_pdf_pages_per_doc": 2000,
|
|
118
|
+
"max_vector_records": 500000,
|
|
119
|
+
}
|
|
120
|
+
if p == "trial":
|
|
121
|
+
return {
|
|
122
|
+
"plan": "trial",
|
|
123
|
+
"docs": True,
|
|
124
|
+
"ml_lab": True,
|
|
125
|
+
"registration": True,
|
|
126
|
+
"theme_toggle": True,
|
|
127
|
+
"branding_controls": True,
|
|
128
|
+
"plugins": True,
|
|
129
|
+
"premium_db_backends": True,
|
|
130
|
+
"audit_export": True,
|
|
131
|
+
"max_users": 9999,
|
|
132
|
+
"max_pages": 9999,
|
|
133
|
+
"max_upload_mb": 9999,
|
|
134
|
+
"max_pdf_pages_per_doc": 999999,
|
|
135
|
+
"max_vector_records": 999999999,
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"plan": "free",
|
|
139
|
+
"docs": False,
|
|
140
|
+
"ml_lab": False,
|
|
141
|
+
"registration": False,
|
|
142
|
+
"theme_toggle": False,
|
|
143
|
+
"branding_controls": False,
|
|
144
|
+
"plugins": False,
|
|
145
|
+
"premium_db_backends": False,
|
|
146
|
+
"audit_export": False,
|
|
147
|
+
"max_users": 1,
|
|
148
|
+
"max_pages": 3,
|
|
149
|
+
"max_upload_mb": 5,
|
|
150
|
+
"max_pdf_pages_per_doc": 20,
|
|
151
|
+
"max_vector_records": 1500,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _catalogue_entitlements(plan: str, *, version: Optional[str] = None, addons: Optional[list] = None) -> Dict[str, Any]:
|
|
156
|
+
"""Resolve entitlements from the catalogue (preferred)."""
|
|
157
|
+
try:
|
|
158
|
+
ent = resolve_entitlements(plan_id=plan, version=version, addons=addons)
|
|
159
|
+
# If catalogue is missing or empty, fall back.
|
|
160
|
+
if not isinstance(ent, dict) or len(ent.keys()) <= 2:
|
|
161
|
+
return _fallback_entitlements(plan)
|
|
162
|
+
return ent
|
|
163
|
+
except Exception:
|
|
164
|
+
return _fallback_entitlements(plan)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def free_entitlements() -> Dict[str, Any]:
|
|
168
|
+
return _catalogue_entitlements("free")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pro_entitlements() -> Dict[str, Any]:
|
|
172
|
+
return _catalogue_entitlements("pro")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def enterprise_entitlements() -> Dict[str, Any]:
|
|
176
|
+
return _catalogue_entitlements("enterprise")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def trial_entitlements() -> Dict[str, Any]:
|
|
180
|
+
return _catalogue_entitlements("trial")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _choose_preset_for_plan(plan: str) -> Dict[str, Any]:
|
|
184
|
+
p = (plan or "").strip().lower()
|
|
185
|
+
if p == "pro":
|
|
186
|
+
return pro_entitlements()
|
|
187
|
+
if p == "enterprise":
|
|
188
|
+
return enterprise_entitlements()
|
|
189
|
+
if p == "trial":
|
|
190
|
+
return trial_entitlements()
|
|
191
|
+
return free_entitlements()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _plan_from_entitlements(ent: Dict[str, Any]) -> str:
|
|
195
|
+
plan = str(ent.get("plan") or "").strip().lower()
|
|
196
|
+
return plan or "free"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _apply_entitlements_to_feature_flags(db: object, ent: Dict[str, Any]) -> None:
|
|
200
|
+
set_setting = getattr(db, "set_setting", None)
|
|
201
|
+
if not callable(set_setting):
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
def _as01(b: Any) -> str:
|
|
205
|
+
return "1" if bool(b) else "0"
|
|
206
|
+
|
|
207
|
+
set_setting("feature.site_documentation", _as01(ent.get("docs")))
|
|
208
|
+
set_setting("feature.ml_lab", _as01(ent.get("ml_lab")))
|
|
209
|
+
set_setting("feature.registration", _as01(ent.get("registration")))
|
|
210
|
+
set_setting("feature.theme_toggle", _as01(ent.get("theme_toggle")))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _read_payload_from_env(env_key: str = "SMX_PREMIUM_ENTITLEMENTS") -> Optional[Dict[str, Any]]:
|
|
214
|
+
raw = os.environ.get(env_key)
|
|
215
|
+
if not raw:
|
|
216
|
+
return None
|
|
217
|
+
data = _safe_json_loads(raw, default=None)
|
|
218
|
+
return data if isinstance(data, dict) else None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read_payload_from_file(client_dir: str, relpath: str = os.path.join("premium", "licence.json")) -> Optional[Dict[str, Any]]:
|
|
222
|
+
p = os.path.join(client_dir, relpath)
|
|
223
|
+
if not os.path.exists(p):
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
227
|
+
data = json.load(f)
|
|
228
|
+
return data if isinstance(data, dict) else None
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def ensure_premium_state(
|
|
234
|
+
*,
|
|
235
|
+
db: object,
|
|
236
|
+
client_dir: str,
|
|
237
|
+
trial_days: int = 7,
|
|
238
|
+
now: Optional[datetime] = None,
|
|
239
|
+
refresh_entitlements: bool = True,
|
|
240
|
+
) -> PremiumState:
|
|
241
|
+
# Prefer the detected project root (your current design),
|
|
242
|
+
# but fall back to provided client_dir if detection fails.
|
|
243
|
+
try:
|
|
244
|
+
detected = str(detect_project_root())
|
|
245
|
+
if detected:
|
|
246
|
+
client_dir = detected
|
|
247
|
+
except Exception:
|
|
248
|
+
client_dir = str(client_dir or "")
|
|
249
|
+
|
|
250
|
+
get_setting = getattr(db, "get_setting", None)
|
|
251
|
+
set_setting = getattr(db, "set_setting", None)
|
|
252
|
+
if not callable(get_setting) or not callable(set_setting):
|
|
253
|
+
ent = free_entitlements()
|
|
254
|
+
return PremiumState(
|
|
255
|
+
trial_days=int(trial_days or 7),
|
|
256
|
+
trial_started_at=None,
|
|
257
|
+
trial_active=False,
|
|
258
|
+
trial_days_left=0,
|
|
259
|
+
plan=_plan_from_entitlements(ent),
|
|
260
|
+
entitlements=ent,
|
|
261
|
+
source="none",
|
|
262
|
+
instance_id=None,
|
|
263
|
+
remote_status=None,
|
|
264
|
+
remote_grace_until=None,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
set_setting("premium.client_dir", client_dir)
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
licence_server = (os.environ.get("SMX_LICENCE_SERVER_URL") or "").strip()
|
|
273
|
+
if not licence_server:
|
|
274
|
+
licence_server = "https://licence.syntaxmatrix.com"
|
|
275
|
+
|
|
276
|
+
# How often to sync remote licence state (minutes).
|
|
277
|
+
try:
|
|
278
|
+
sync_minutes = int((os.environ.get("SMX_LICENCE_SYNC_MINUTES") or "10").strip() or "10")
|
|
279
|
+
except Exception:
|
|
280
|
+
sync_minutes = 10
|
|
281
|
+
if sync_minutes < 1:
|
|
282
|
+
sync_minutes = 1
|
|
283
|
+
|
|
284
|
+
now_dt = now or _utcnow()
|
|
285
|
+
|
|
286
|
+
def _now_iso() -> str:
|
|
287
|
+
return now_dt.replace(microsecond=0).isoformat() + "Z"
|
|
288
|
+
|
|
289
|
+
def _sync_due(last_iso: str) -> bool:
|
|
290
|
+
last = _parse_iso_utc(last_iso)
|
|
291
|
+
if not last:
|
|
292
|
+
return True
|
|
293
|
+
return (now_dt - last).total_seconds() >= sync_minutes * 60
|
|
294
|
+
|
|
295
|
+
def _post(url: str, payload: dict) -> dict:
|
|
296
|
+
import urllib.request
|
|
297
|
+
|
|
298
|
+
body = json.dumps(payload).encode("utf-8")
|
|
299
|
+
req = urllib.request.Request(
|
|
300
|
+
url,
|
|
301
|
+
data=body,
|
|
302
|
+
headers={"Content-Type": "application/json"},
|
|
303
|
+
method="POST",
|
|
304
|
+
)
|
|
305
|
+
try:
|
|
306
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
307
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
308
|
+
return json.loads(raw) if raw else {}
|
|
309
|
+
except Exception:
|
|
310
|
+
return {}
|
|
311
|
+
|
|
312
|
+
remote_status: Optional[str] = None
|
|
313
|
+
remote_grace_until: Optional[str] = None
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
trial_days_int = int(trial_days)
|
|
317
|
+
except Exception:
|
|
318
|
+
trial_days_int = 14
|
|
319
|
+
if trial_days_int <= 0:
|
|
320
|
+
trial_days_int = 14
|
|
321
|
+
|
|
322
|
+
inst_fp = ""
|
|
323
|
+
try:
|
|
324
|
+
inst_fp = instance_fingerprint()
|
|
325
|
+
if inst_fp:
|
|
326
|
+
set_setting("premium.instance_id", inst_fp)
|
|
327
|
+
except Exception:
|
|
328
|
+
inst_fp = ""
|
|
329
|
+
|
|
330
|
+
remote_status = (get_setting("premium.remote_status", "") or "").strip().lower() or None
|
|
331
|
+
remote_grace_until = (get_setting("premium.remote_grace_until", "") or "").strip() or None
|
|
332
|
+
|
|
333
|
+
if licence_server and refresh_entitlements and inst_fp:
|
|
334
|
+
last_sync = get_setting("premium.last_sync_at", "") or ""
|
|
335
|
+
if _sync_due(last_sync):
|
|
336
|
+
resp = _post(f"{licence_server}/v1/licence", {"instance_fp": inst_fp})
|
|
337
|
+
if isinstance(resp, dict) and resp.get("ok"):
|
|
338
|
+
st = str(resp.get("status") or "").strip().lower() or ""
|
|
339
|
+
if st:
|
|
340
|
+
remote_status = st
|
|
341
|
+
try:
|
|
342
|
+
set_setting("premium.remote_status", st)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
gu_iso = None
|
|
347
|
+
if resp.get("grace_until_iso"):
|
|
348
|
+
gu_iso = str(resp.get("grace_until_iso") or "").strip() or None
|
|
349
|
+
elif resp.get("grace_until"):
|
|
350
|
+
try:
|
|
351
|
+
gu_ts = int(resp.get("grace_until"))
|
|
352
|
+
gu_iso = _iso_utc(datetime.utcfromtimestamp(gu_ts))
|
|
353
|
+
except Exception:
|
|
354
|
+
gu_iso = None
|
|
355
|
+
|
|
356
|
+
remote_grace_until = gu_iso
|
|
357
|
+
try:
|
|
358
|
+
set_setting("premium.remote_grace_until", gu_iso or "")
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
lic_path = os.path.join(client_dir, "premium", "licence.json")
|
|
363
|
+
if st in ("active", "past_due") and isinstance(resp.get("licence"), dict):
|
|
364
|
+
try:
|
|
365
|
+
os.makedirs(os.path.dirname(lic_path), exist_ok=True)
|
|
366
|
+
with open(lic_path, "w", encoding="utf-8") as f:
|
|
367
|
+
json.dump(resp["licence"], f, ensure_ascii=False, indent=2)
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
elif st == "revoked":
|
|
371
|
+
try:
|
|
372
|
+
if os.path.exists(lic_path):
|
|
373
|
+
os.remove(lic_path)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
set_setting("premium.last_sync_at", _now_iso())
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
remote_revoked = (str(remote_status or "").strip().lower() == "revoked")
|
|
383
|
+
|
|
384
|
+
# Trial tracking
|
|
385
|
+
started_raw = get_setting("premium.trial_started_at", "") or ""
|
|
386
|
+
started_dt = _parse_iso_utc(started_raw)
|
|
387
|
+
if started_dt is None:
|
|
388
|
+
started_dt = now_dt
|
|
389
|
+
started_raw = _iso_utc(started_dt)
|
|
390
|
+
try:
|
|
391
|
+
set_setting("premium.trial_started_at", started_raw)
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
elapsed = now_dt - started_dt
|
|
396
|
+
trial_active = elapsed < timedelta(days=trial_days_int)
|
|
397
|
+
days_left = 0
|
|
398
|
+
if trial_active:
|
|
399
|
+
remaining = timedelta(days=trial_days_int) - elapsed
|
|
400
|
+
days_left = max(1, int((remaining.total_seconds() + 86399) // 86400))
|
|
401
|
+
|
|
402
|
+
if remote_revoked:
|
|
403
|
+
trial_active = False
|
|
404
|
+
days_left = 0
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
set_setting("premium.trial_days", str(trial_days_int))
|
|
408
|
+
set_setting("premium.trial_active", "1" if trial_active else "0")
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
source = "none"
|
|
413
|
+
ent: Dict[str, Any] = {}
|
|
414
|
+
last_err = ""
|
|
415
|
+
|
|
416
|
+
allow_unsigned = trial_active or _truthy(os.environ.get("SMX_PREMIUM_ALLOW_UNSIGNED", "0"))
|
|
417
|
+
|
|
418
|
+
def _accept_payload(payload: Optional[Dict[str, Any]], src: str) -> Optional[Dict[str, Any]]:
|
|
419
|
+
nonlocal last_err, source
|
|
420
|
+
if not payload:
|
|
421
|
+
return None
|
|
422
|
+
verified = verify_licence_payload(
|
|
423
|
+
payload,
|
|
424
|
+
now=now_dt,
|
|
425
|
+
allow_unsigned=allow_unsigned,
|
|
426
|
+
)
|
|
427
|
+
if not verified.ok:
|
|
428
|
+
last_err = verified.error or "Licence rejected"
|
|
429
|
+
return None
|
|
430
|
+
source = src
|
|
431
|
+
return verified.entitlements
|
|
432
|
+
|
|
433
|
+
def _resolve_from_claims(claims: Dict[str, Any]) -> Dict[str, Any]:
|
|
434
|
+
plan_claim = str(claims.get("plan_id") or claims.get("plan") or "free").strip().lower() or "free"
|
|
435
|
+
ver = str(claims.get("entitlement_version") or claims.get("entitlementVersion") or "").strip() or None
|
|
436
|
+
addons = claims.get("addons") if isinstance(claims.get("addons"), list) else None
|
|
437
|
+
overrides = claims.get("entitlements") if isinstance(claims.get("entitlements"), dict) else None
|
|
438
|
+
|
|
439
|
+
ent_resolved = _catalogue_entitlements(plan_claim, version=ver, addons=addons)
|
|
440
|
+
|
|
441
|
+
if overrides:
|
|
442
|
+
for k, v in overrides.items():
|
|
443
|
+
if k in ("sig", "instance_id", "issued_at", "expires_at"):
|
|
444
|
+
continue
|
|
445
|
+
ent_resolved[k] = v
|
|
446
|
+
|
|
447
|
+
return ent_resolved
|
|
448
|
+
|
|
449
|
+
ent = {}
|
|
450
|
+
last_err = ""
|
|
451
|
+
|
|
452
|
+
if remote_revoked:
|
|
453
|
+
ent = free_entitlements()
|
|
454
|
+
source = "remote"
|
|
455
|
+
last_err = ""
|
|
456
|
+
else:
|
|
457
|
+
if refresh_entitlements:
|
|
458
|
+
payload = _read_payload_from_file(client_dir)
|
|
459
|
+
claims = _accept_payload(payload, "file") or {}
|
|
460
|
+
if claims:
|
|
461
|
+
ent = _resolve_from_claims(claims)
|
|
462
|
+
source = "file"
|
|
463
|
+
|
|
464
|
+
if not ent:
|
|
465
|
+
if trial_active:
|
|
466
|
+
ent = trial_entitlements()
|
|
467
|
+
source = "trial"
|
|
468
|
+
else:
|
|
469
|
+
ent = free_entitlements()
|
|
470
|
+
source = "none"
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
set_setting("premium.last_licence_error", last_err)
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
plan = _plan_from_entitlements(ent)
|
|
478
|
+
|
|
479
|
+
trial_active_eff = trial_active
|
|
480
|
+
days_left_eff = days_left
|
|
481
|
+
if plan in ("pro", "business", "enterprise"):
|
|
482
|
+
trial_active_eff = False
|
|
483
|
+
days_left_eff = 0
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
set_setting("premium.plan", plan)
|
|
487
|
+
set_setting("premium.entitlements", json.dumps(ent, ensure_ascii=False))
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
_apply_entitlements_to_feature_flags(db, ent)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
return PremiumState(
|
|
497
|
+
trial_days=trial_days_int,
|
|
498
|
+
trial_started_at=started_raw,
|
|
499
|
+
trial_active=trial_active_eff,
|
|
500
|
+
trial_days_left=days_left_eff,
|
|
501
|
+
plan=plan,
|
|
502
|
+
entitlements=ent,
|
|
503
|
+
source=source,
|
|
504
|
+
instance_id=(inst_fp or None),
|
|
505
|
+
remote_status=remote_status,
|
|
506
|
+
remote_grace_until=remote_grace_until,
|
|
507
|
+
)
|