feat(backend): offline-capable signed licensing; fix expiry grace; state in data dir (KI-006)
- services/license.py: verifies the cloud's Ed25519-signed license token
(public key built in) and decides purely: valid → licensed until expiry,
then a 5-day grace, then blocked - never mid-service (deferred while a
workday is open, applied at close). Works offline for as long as the
license lasts: the "unlicensed after 72h without heartbeat" rule is gone.
- Tamper resistance: an edited token fails the signature ("unverified");
a clock earlier than the latest provable time (token issued_at, newest
order in the DB, stored high-water mark; 1 day tolerance) → "clock".
- apply_license() re-evaluates from the stored token at startup, after
every heartbeat attempt and when a workday closes. Cloud lock/unlock from
the token keeps the workday-deferred behaviour. Transition: a cloud
without tokens is trusted 72h per successful heartbeat.
- FIX: the promised 5-day grace after expiry never happened - the cloud's
licensed=false was applied immediately (402 on everything).
- FIX: license_state.json lived inside the container and was lost on every
re-creation; it now lives in the data volume (old path read once).
- /api/system/status: offline_days, license_verified, license_problem,
grace_over; lock_reason "clock"/"unverified"; grace days from the license
module (rounded up).
Tests: 18 unit checks (signature, tamper, other site/key, 364 days
offline, grace ±workday, inactive, clock rollback, transition) + 17 E2E
checks with a real cloud + site process (400 days offline, tampered file →
402, clock behind newest order, expiry deferred until workday close,
renewal, remote lock/unlock).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -168,8 +168,10 @@ def close_business_day(
|
||||
if license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
from services.cloud_sync import _persist_state
|
||||
_persist_state()
|
||||
# Re-evaluate the license now that no workday is open (an expiry whose grace
|
||||
# ended mid-service takes effect here) — also persists the state
|
||||
from services.cloud_sync import apply_license
|
||||
apply_license()
|
||||
|
||||
return day
|
||||
|
||||
|
||||
Reference in New Issue
Block a user