Przeglądaj źródła

Sign white-label licence tokens and verify them locally (#379)

The self-hosted gate read two rows in the customer's own database. It now
trusts only a token signed by torqvoice.com, bound to the organization,
and refreshed within 14 days. Existing installs fetch their token on boot.
Bernt Christian Egeland 2 tygodni temu
rodzic
commit
df5c91b191
39 zmienionych plików z 1093 dodań i 421 usunięć
  1. 3 0
      messages/de/navigation.json
  2. 8 1
      messages/de/settings.json
  3. 3 0
      messages/en/navigation.json
  4. 8 1
      messages/en/settings.json
  5. 3 0
      messages/es/navigation.json
  6. 8 1
      messages/es/settings.json
  7. 3 0
      messages/fr/navigation.json
  8. 8 1
      messages/fr/settings.json
  9. 3 0
      messages/it/navigation.json
  10. 8 1
      messages/it/settings.json
  11. 3 0
      messages/lt/navigation.json
  12. 8 1
      messages/lt/settings.json
  13. 3 0
      messages/nb/navigation.json
  14. 8 1
      messages/nb/settings.json
  15. 3 0
      messages/nl/navigation.json
  16. 8 1
      messages/nl/settings.json
  17. 3 0
      messages/pl/navigation.json
  18. 8 1
      messages/pl/settings.json
  19. 3 0
      messages/pt-BR/navigation.json
  20. 8 1
      messages/pt-BR/settings.json
  21. 3 0
      messages/ru/navigation.json
  22. 8 1
      messages/ru/settings.json
  23. 3 0
      messages/tr/navigation.json
  24. 8 1
      messages/tr/settings.json
  25. 56 9
      src/__tests__/lib/features.test.ts
  26. 120 93
      src/__tests__/lib/license-expiry.test.ts
  27. 131 0
      src/__tests__/lib/license-token.test.ts
  28. 29 12
      src/app/(authenticated)/layout.tsx
  29. 10 7
      src/app/(authenticated)/settings/license/page.tsx
  30. 12 1
      src/components/license-expiry-context.tsx
  31. 28 4
      src/components/page-header.tsx
  32. 27 146
      src/features/settings/Actions/validateLicense.ts
  33. 57 10
      src/features/settings/Components/license-settings.tsx
  34. 3 0
      src/features/settings/Schema/settingsSchema.ts
  35. 150 122
      src/lib/cron/check-licenses.ts
  36. 20 5
      src/lib/features.ts
  37. 14 0
      src/lib/license/public-keys.ts
  38. 151 0
      src/lib/license/revalidate.ts
  39. 153 0
      src/lib/license/token.ts

+ 3 - 0
messages/de/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Ihre Lizenz läuft morgen ab.",
   "licenseExpiresDays": "Ihre Lizenz läuft in {days} Tagen ab.",
   "licenseRenew": "Verlängern",
+  "licenseUnverifiedDays": "Ihre Lizenz konnte nicht überprüft werden. Das Torqvoice-Branding kehrt in {days} Tagen zurück, sofern dieser Server torqvoice.com nicht erreichen kann.",
+  "licenseUnverifiedNow": "Ihre Lizenz konnte nicht überprüft werden und das Torqvoice-Branding ist zurückgekehrt.",
+  "licenseVerify": "Überprüfen",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Kunden",

+ 8 - 1
messages/de/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Lizenz erfolgreich validiert",
     "invalid": "Ungültiger Lizenzschlüssel",
     "failedValidate": "Lizenz konnte nicht validiert werden",
-    "keyHint": "Jeder Lizenzschlüssel ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden."
+    "keyHint": "Jeder Lizenzschlüssel ist an diese Organisation gebunden und kann nicht für mehrere Organisationen verwendet werden.",
+    "expired": "Abgelaufen",
+    "unverified": "Nicht überprüft",
+    "expiresOn": "Läuft ab: {date}",
+    "unreachable": "torqvoice.com war nicht erreichbar. Die auf diesem Server gespeicherte Lizenz bleibt unverändert.",
+    "unverifiedHint": "Dieser Server konnte die Lizenz seit {days} Tagen nicht bei torqvoice.com bestätigen. Nach {max} Tagen ohne erfolgreiche Prüfung kehrt das Torqvoice-Branding zurück.",
+    "rejected": "torqvoice.com hat diesen Schlüssel nicht akzeptiert: {reason}",
+    "signedHint": "Die Validierung wird von torqvoice.com signiert und täglich erneuert, daher braucht der Server ausgehenden Zugriff auf torqvoice.com."
   },
   "about": {
     "title": "Uber Torqvoice",

+ 3 - 0
messages/en/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Your license expires tomorrow.",
   "licenseExpiresDays": "Your license expires in {days} days.",
   "licenseRenew": "Renew",
+  "licenseUnverifiedDays": "Your license could not be verified. Torqvoice branding returns in {days} days unless this server can reach torqvoice.com.",
+  "licenseUnverifiedNow": "Your license could not be verified and Torqvoice branding has returned.",
+  "licenseVerify": "Verify",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Clients",

+ 8 - 1
messages/en/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "License validated successfully",
     "invalid": "Invalid license key",
     "failedValidate": "Failed to validate license",
-    "keyHint": "Each license key is tied to this organization and cannot be shared across multiple organizations."
+    "keyHint": "Each license key is tied to this organization and cannot be shared across multiple organizations.",
+    "expired": "Expired",
+    "unverified": "Unverified",
+    "expiresOn": "Expires: {date}",
+    "unreachable": "torqvoice.com could not be reached. The licence stored on this server is unchanged.",
+    "unverifiedHint": "This server has not been able to confirm the licence with torqvoice.com for {days} days. Torqvoice branding returns after {max} days without a successful check.",
+    "rejected": "torqvoice.com did not accept this key: {reason}",
+    "signedHint": "Validation is signed by torqvoice.com and re-checked daily, so the server needs outbound access to torqvoice.com."
   },
   "about": {
     "title": "About Torqvoice",

+ 3 - 0
messages/es/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Su licencia expira mañana.",
   "licenseExpiresDays": "Su licencia expira en {days} días.",
   "licenseRenew": "Renovar",
+  "licenseUnverifiedDays": "No se pudo verificar su licencia. La marca Torqvoice volverá en {days} días a menos que este servidor pueda conectarse a torqvoice.com.",
+  "licenseUnverifiedNow": "No se pudo verificar su licencia y la marca Torqvoice ha vuelto.",
+  "licenseVerify": "Verificar",
   "sidebar": {
     "dashboard": "Panel de control",
     "clients": "Clientes",

+ 8 - 1
messages/es/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licencia validada exitosamente",
     "invalid": "Clave de licencia invalida",
     "failedValidate": "Error al validar la licencia",
-    "keyHint": "Cada clave de licencia esta vinculada a esta organización y no puede compartirse entre varias organizaciones."
+    "keyHint": "Cada clave de licencia esta vinculada a esta organización y no puede compartirse entre varias organizaciones.",
+    "expired": "Expirada",
+    "unverified": "Sin verificar",
+    "expiresOn": "Expira: {date}",
+    "unreachable": "No se pudo conectar con torqvoice.com. La licencia guardada en este servidor no ha cambiado.",
+    "unverifiedHint": "Este servidor no ha podido confirmar la licencia con torqvoice.com desde hace {days} días. La marca Torqvoice vuelve tras {max} días sin una comprobación correcta.",
+    "rejected": "torqvoice.com no aceptó esta clave: {reason}",
+    "signedHint": "La validación está firmada por torqvoice.com y se repite a diario, por lo que el servidor necesita acceso saliente a torqvoice.com."
   },
   "about": {
     "title": "Acerca de Torqvoice",

+ 3 - 0
messages/fr/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Votre licence expire demain.",
   "licenseExpiresDays": "Votre licence expire dans {days} jours.",
   "licenseRenew": "Renouveler",
+  "licenseUnverifiedDays": "Votre licence n'a pas pu être vérifiée. La marque Torqvoice réapparaîtra dans {days} jours si ce serveur ne peut pas joindre torqvoice.com.",
+  "licenseUnverifiedNow": "Votre licence n'a pas pu être vérifiée et la marque Torqvoice est réapparue.",
+  "licenseVerify": "Vérifier",
   "sidebar": {
     "dashboard": "Tableau de bord",
     "clients": "Clients",

+ 8 - 1
messages/fr/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licence validee avec succès",
     "invalid": "Cle de licence invalide",
     "failedValidate": "Échec de la validation de la licence",
-    "keyHint": "Chaque cle de licence est liée a cette organisation et ne peut pas être partagee entre plusieurs organisations."
+    "keyHint": "Chaque cle de licence est liée a cette organisation et ne peut pas être partagee entre plusieurs organisations.",
+    "expired": "Expirée",
+    "unverified": "Non vérifiée",
+    "expiresOn": "Expire le : {date}",
+    "unreachable": "Impossible de joindre torqvoice.com. La licence enregistrée sur ce serveur reste inchangée.",
+    "unverifiedHint": "Ce serveur n'a pas pu confirmer la licence auprès de torqvoice.com depuis {days} jours. La marque Torqvoice réapparaît après {max} jours sans vérification réussie.",
+    "rejected": "torqvoice.com n'a pas accepté cette clé : {reason}",
+    "signedHint": "La validation est signée par torqvoice.com et renouvelée chaque jour ; le serveur doit donc pouvoir joindre torqvoice.com."
   },
   "about": {
     "title": "A propos de Torqvoice",

+ 3 - 0
messages/it/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "La tua licenza scade domani.",
   "licenseExpiresDays": "La tua licenza scade tra {days} giorni.",
   "licenseRenew": "Rinnova",
+  "licenseUnverifiedDays": "Non è stato possibile verificare la licenza. Il marchio Torqvoice tornerà tra {days} giorni se questo server non riesce a raggiungere torqvoice.com.",
+  "licenseUnverifiedNow": "Non è stato possibile verificare la licenza e il marchio Torqvoice è tornato.",
+  "licenseVerify": "Verifica",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Clienti",

+ 8 - 1
messages/it/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licenza validata con successo",
     "invalid": "Chiave di licenza non valida",
     "failedValidate": "Validazione licenza non riuscita",
-    "keyHint": "Ogni chiave di licenza e legata a questa organizzazione e non puo essere condivisa tra piu organizzazioni."
+    "keyHint": "Ogni chiave di licenza e legata a questa organizzazione e non puo essere condivisa tra piu organizzazioni.",
+    "expired": "Scaduta",
+    "unverified": "Non verificata",
+    "expiresOn": "Scade: {date}",
+    "unreachable": "Impossibile raggiungere torqvoice.com. La licenza salvata su questo server resta invariata.",
+    "unverifiedHint": "Questo server non riesce a confermare la licenza con torqvoice.com da {days} giorni. Il marchio Torqvoice torna dopo {max} giorni senza una verifica riuscita.",
+    "rejected": "torqvoice.com non ha accettato questa chiave: {reason}",
+    "signedHint": "La convalida è firmata da torqvoice.com e ripetuta ogni giorno, quindi il server deve poter raggiungere torqvoice.com."
   },
   "about": {
     "title": "Informazioni su Torqvoice",

+ 3 - 0
messages/lt/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Jūsų licencija baigiasi rytoj.",
   "licenseExpiresDays": "Jūsų licencija baigiasi po {days} d.",
   "licenseRenew": "Atnaujinti",
+  "licenseUnverifiedDays": "Nepavyko patikrinti jūsų licencijos. Torqvoice prekės ženklas grįš po {days} d., jei šis serveris nepasieks torqvoice.com.",
+  "licenseUnverifiedNow": "Nepavyko patikrinti jūsų licencijos ir Torqvoice prekės ženklas grįžo.",
+  "licenseVerify": "Patikrinti",
   "sidebar": {
     "dashboard": "Valdymo skydelis",
     "clients": "Klientai",

+ 8 - 1
messages/lt/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licencija sėkmingai patvirtinta",
     "invalid": "Neteisingas licencijos raktas",
     "failedValidate": "Nepavyko patvirtinti licencijos",
-    "keyHint": "Kiekvienas licencijos raktas susietas su šia organizacija ir negali būti naudojamas keliose organizacijose."
+    "keyHint": "Kiekvienas licencijos raktas susietas su šia organizacija ir negali būti naudojamas keliose organizacijose.",
+    "expired": "Pasibaigusi",
+    "unverified": "Nepatikrinta",
+    "expiresOn": "Galioja iki: {date}",
+    "unreachable": "Nepavyko pasiekti torqvoice.com. Šiame serveryje saugoma licencija nepakito.",
+    "unverifiedHint": "Šis serveris jau {days} d. negali patvirtinti licencijos su torqvoice.com. Po {max} d. be sėkmingo patikrinimo Torqvoice prekės ženklas grįžta.",
+    "rejected": "torqvoice.com nepriėmė šio rakto: {reason}",
+    "signedHint": "Patikrinimą pasirašo torqvoice.com ir jis kartojamas kasdien, todėl serveriui reikia išeinančio ryšio su torqvoice.com."
   },
   "about": {
     "title": "Apie Torqvoice",

+ 3 - 0
messages/nb/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Lisensen din utløper i morgen.",
   "licenseExpiresDays": "Lisensen din utløper om {days} dager.",
   "licenseRenew": "Forny",
+  "licenseUnverifiedDays": "Lisensen din kunne ikke bekreftes. Torqvoice-merkingen kommer tilbake om {days} dager med mindre denne serveren når torqvoice.com.",
+  "licenseUnverifiedNow": "Lisensen din kunne ikke bekreftes, og Torqvoice-merkingen er tilbake.",
+  "licenseVerify": "Bekreft",
   "sidebar": {
     "dashboard": "Dashbord",
     "clients": "Klienter",

+ 8 - 1
messages/nb/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Lisens validert",
     "invalid": "Ugyldig lisensnokkel",
     "failedValidate": "Kunne ikke validere lisens",
-    "keyHint": "Hver lisensnokkel er knyttet til denne organisasjonen og kan ikke deles mellom flere organisasjoner."
+    "keyHint": "Hver lisensnokkel er knyttet til denne organisasjonen og kan ikke deles mellom flere organisasjoner.",
+    "expired": "Utløpt",
+    "unverified": "Ikke bekreftet",
+    "expiresOn": "Utløper: {date}",
+    "unreachable": "torqvoice.com kunne ikke nås. Lisensen som er lagret på denne serveren er uendret.",
+    "unverifiedHint": "Denne serveren har ikke kunnet bekrefte lisensen hos torqvoice.com på {days} dager. Torqvoice-merkingen kommer tilbake etter {max} dager uten en vellykket sjekk.",
+    "rejected": "torqvoice.com godtok ikke denne nøkkelen: {reason}",
+    "signedHint": "Valideringen signeres av torqvoice.com og gjentas daglig, så serveren trenger utgående tilgang til torqvoice.com."
   },
   "about": {
     "title": "Om Torqvoice",

+ 3 - 0
messages/nl/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Uw licentie verloopt morgen.",
   "licenseExpiresDays": "Uw licentie verloopt over {days} dagen.",
   "licenseRenew": "Verlengen",
+  "licenseUnverifiedDays": "Uw licentie kon niet worden geverifieerd. De Torqvoice-branding keert over {days} dagen terug tenzij deze server torqvoice.com kan bereiken.",
+  "licenseUnverifiedNow": "Uw licentie kon niet worden geverifieerd en de Torqvoice-branding is teruggekeerd.",
+  "licenseVerify": "Verifiëren",
   "sidebar": {
     "dashboard": "Dashboard",
     "clients": "Klanten",

+ 8 - 1
messages/nl/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licentie succesvol gevalideerd",
     "invalid": "Ongeldige licentiesleutel",
     "failedValidate": "Licentie valideren mislukt",
-    "keyHint": "Elke licentiesleutel is gekoppeld aan deze organisatie en kan niet worden gedeeld tussen meerdere organisaties."
+    "keyHint": "Elke licentiesleutel is gekoppeld aan deze organisatie en kan niet worden gedeeld tussen meerdere organisaties.",
+    "expired": "Verlopen",
+    "unverified": "Niet geverifieerd",
+    "expiresOn": "Verloopt: {date}",
+    "unreachable": "torqvoice.com was niet bereikbaar. De licentie op deze server is ongewijzigd.",
+    "unverifiedHint": "Deze server kan de licentie al {days} dagen niet bevestigen bij torqvoice.com. Na {max} dagen zonder geslaagde controle keert de Torqvoice-branding terug.",
+    "rejected": "torqvoice.com heeft deze sleutel niet geaccepteerd: {reason}",
+    "signedHint": "De validatie wordt door torqvoice.com ondertekend en dagelijks herhaald, dus de server heeft uitgaande toegang tot torqvoice.com nodig."
   },
   "about": {
     "title": "Over Torqvoice",

+ 3 - 0
messages/pl/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Twoja licencja wygasa jutro.",
   "licenseExpiresDays": "Twoja licencja wygasa za {days} dni.",
   "licenseRenew": "Odnów",
+  "licenseUnverifiedDays": "Nie udało się zweryfikować licencji. Oznaczenie Torqvoice wróci za {days} dni, jeśli ten serwer nie połączy się z torqvoice.com.",
+  "licenseUnverifiedNow": "Nie udało się zweryfikować licencji i oznaczenie Torqvoice wróciło.",
+  "licenseVerify": "Zweryfikuj",
   "sidebar": {
     "dashboard": "Pulpit",
     "clients": "Klienci",

+ 8 - 1
messages/pl/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licencja zweryfikowana pomyślnie",
     "invalid": "Nieprawidlowy klucz licencji",
     "failedValidate": "Nie udało się zweryfikować licencji",
-    "keyHint": "Każdy klucz licencji jest powiazany z ta organizacja i nie może być wspoldzielony między wieloma organizacjami."
+    "keyHint": "Każdy klucz licencji jest powiazany z ta organizacja i nie może być wspoldzielony między wieloma organizacjami.",
+    "expired": "Wygasła",
+    "unverified": "Niezweryfikowana",
+    "expiresOn": "Wygasa: {date}",
+    "unreachable": "Nie udało się połączyć z torqvoice.com. Licencja zapisana na tym serwerze pozostaje bez zmian.",
+    "unverifiedHint": "Ten serwer od {days} dni nie może potwierdzić licencji w torqvoice.com. Po {max} dniach bez udanego sprawdzenia oznaczenie Torqvoice wraca.",
+    "rejected": "torqvoice.com nie przyjął tego klucza: {reason}",
+    "signedHint": "Walidacja jest podpisywana przez torqvoice.com i powtarzana codziennie, więc serwer potrzebuje wychodzącego dostępu do torqvoice.com."
   },
   "about": {
     "title": "O Torqvoice",

+ 3 - 0
messages/pt-BR/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Sua licença expira amanhã.",
   "licenseExpiresDays": "Sua licença expira em {days} dias.",
   "licenseRenew": "Renovar",
+  "licenseUnverifiedDays": "Não foi possível verificar sua licença. A marca Torqvoice voltará em {days} dias, a menos que este servidor consiga acessar torqvoice.com.",
+  "licenseUnverifiedNow": "Não foi possível verificar sua licença e a marca Torqvoice voltou.",
+  "licenseVerify": "Verificar",
   "sidebar": {
     "dashboard": "Painel",
     "clients": "Clientes",

+ 8 - 1
messages/pt-BR/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Licença validada com sucesso",
     "invalid": "Chave de licença invalida",
     "failedValidate": "Falha ao validar a licença",
-    "keyHint": "Cada chave de licença esta vinculada a esta organização e nao pode ser compartilhada entre varias organizações."
+    "keyHint": "Cada chave de licença esta vinculada a esta organização e nao pode ser compartilhada entre varias organizações.",
+    "expired": "Expirada",
+    "unverified": "Não verificada",
+    "expiresOn": "Expira em: {date}",
+    "unreachable": "Não foi possível acessar torqvoice.com. A licença salva neste servidor permanece inalterada.",
+    "unverifiedHint": "Este servidor não consegue confirmar a licença com torqvoice.com há {days} dias. A marca Torqvoice volta após {max} dias sem uma verificação bem-sucedida.",
+    "rejected": "torqvoice.com não aceitou esta chave: {reason}",
+    "signedHint": "A validação é assinada por torqvoice.com e repetida diariamente, portanto o servidor precisa de acesso de saída a torqvoice.com."
   },
   "about": {
     "title": "Sobre o Torqvoice",

+ 3 - 0
messages/ru/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Ваша лицензия истекает завтра.",
   "licenseExpiresDays": "Ваша лицензия истекает через {days} дней.",
   "licenseRenew": "Продлить",
+  "licenseUnverifiedDays": "Не удалось проверить лицензию. Брендинг Torqvoice вернётся через {days} дн., если этот сервер не сможет связаться с torqvoice.com.",
+  "licenseUnverifiedNow": "Не удалось проверить лицензию, и брендинг Torqvoice вернулся.",
+  "licenseVerify": "Проверить",
   "sidebar": {
     "dashboard": "Панель управления",
     "clients": "Клиенты",

+ 8 - 1
messages/ru/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Лицензия успешно подтверждена",
     "invalid": "Недействительный лицензионный ключ",
     "failedValidate": "Не удалось проверить лицензию",
-    "keyHint": "Каждый лицензионный ключ привязан к этой организации и не может быть использован в нескольких организациях."
+    "keyHint": "Каждый лицензионный ключ привязан к этой организации и не может быть использован в нескольких организациях.",
+    "expired": "Истекла",
+    "unverified": "Не проверена",
+    "expiresOn": "Истекает: {date}",
+    "unreachable": "Не удалось связаться с torqvoice.com. Лицензия, сохранённая на этом сервере, не изменилась.",
+    "unverifiedHint": "Этот сервер уже {days} дн. не может подтвердить лицензию на torqvoice.com. Через {max} дн. без успешной проверки брендинг Torqvoice возвращается.",
+    "rejected": "torqvoice.com не принял этот ключ: {reason}",
+    "signedHint": "Проверка подписывается torqvoice.com и повторяется ежедневно, поэтому серверу нужен исходящий доступ к torqvoice.com."
   },
   "about": {
     "title": "О Torqvoice",

+ 3 - 0
messages/tr/navigation.json

@@ -86,6 +86,9 @@
   "licenseExpiresTomorrow": "Lisansınız yarın sona eriyor.",
   "licenseExpiresDays": "Lisansınız {days} gün içinde sona eriyor.",
   "licenseRenew": "Yenile",
+  "licenseUnverifiedDays": "Lisansınız doğrulanamadı. Bu sunucu torqvoice.com'a erişemezse Torqvoice markası {days} gün içinde geri döner.",
+  "licenseUnverifiedNow": "Lisansınız doğrulanamadı ve Torqvoice markası geri döndü.",
+  "licenseVerify": "Doğrula",
   "sidebar": {
     "dashboard": "Kontrol Paneli",
     "clients": "Müşteriler",

+ 8 - 1
messages/tr/settings.json

@@ -1264,7 +1264,14 @@
     "validated": "Lisans başarıyla doğrulandı",
     "invalid": "Geçersiz lisans anahtarı",
     "failedValidate": "Lisans doğrulanamadı",
-    "keyHint": "Her lisans anahtarı bu kuruluşa bağlıdır ve birden fazla kuruluşta paylaşılamaz."
+    "keyHint": "Her lisans anahtarı bu kuruluşa bağlıdır ve birden fazla kuruluşta paylaşılamaz.",
+    "expired": "Süresi doldu",
+    "unverified": "Doğrulanmadı",
+    "expiresOn": "Bitiş: {date}",
+    "unreachable": "torqvoice.com'a ulaşılamadı. Bu sunucuda kayıtlı lisans değişmedi.",
+    "unverifiedHint": "Bu sunucu {days} gündür lisansı torqvoice.com ile doğrulayamıyor. Başarılı bir kontrol olmadan {max} gün sonra Torqvoice markası geri döner.",
+    "rejected": "torqvoice.com bu anahtarı kabul etmedi: {reason}",
+    "signedHint": "Doğrulama torqvoice.com tarafından imzalanır ve her gün yenilenir, bu nedenle sunucunun torqvoice.com'a giden erişimi olmalıdır."
   },
   "about": {
     "title": "Torqvoice Hakkında",

+ 56 - 9
src/__tests__/lib/features.test.ts

@@ -7,15 +7,32 @@ vi.mock('@/lib/db', () => ({
   },
 }))
 
+vi.mock('@/lib/license/token', () => ({
+  verifyLicenseToken: vi.fn(),
+}))
+
+vi.mock('@/lib/license/revalidate', () => ({
+  scheduleLicenseSelfHeal: vi.fn(),
+}))
+
 import { db } from '@/lib/db'
 import { getFeatures, PLAN_FEATURES } from '@/lib/features'
+import { verifyLicenseToken } from '@/lib/license/token'
+import { scheduleLicenseSelfHeal } from '@/lib/license/revalidate'
 
 const mockFindUnique = vi.mocked(db.subscription.findUnique)
 const mockFindMany = vi.mocked(db.appSetting.findMany)
+const mockVerify = vi.mocked(verifyLicenseToken)
+const mockSelfHeal = vi.mocked(scheduleLicenseSelfHeal)
+
+function verification(status: 'missing' | 'invalid' | 'expired' | 'stale' | 'valid') {
+  return { status, payload: null, ageDays: null, daysUntilExpiry: null }
+}
 
 beforeEach(() => {
   vi.resetAllMocks()
   vi.unstubAllEnvs()
+  mockVerify.mockReturnValue(verification('missing'))
 })
 
 describe('getFeatures — cloud mode', () => {
@@ -135,32 +152,62 @@ describe('getFeatures — self-hosted mode', () => {
     expect(features.payments).toBe(true)
     expect(features.brandingRemoved).toBe(false)
     expect(features.customPlatformName).toBe(false)
+    expect(mockSelfHeal).not.toHaveBeenCalled()
   })
 
-  it('unlocks branding when license is valid and not expired', async () => {
-    const future = new Date(Date.now() + 86400000).toISOString()
+  it('unlocks branding only on a verified, fresh, unexpired token', async () => {
     mockFindMany.mockResolvedValue([
-      { key: 'license.valid', value: 'true' },
-      { key: 'license.expiresAt', value: future },
+      { key: 'license.key', value: 'KEY' },
+      { key: 'license.token', value: 'tvl1.x.y' },
     ] as any)
+    mockVerify.mockReturnValue(verification('valid'))
     const features = await getFeatures('org-1')
+    expect(mockVerify).toHaveBeenCalledWith('tvl1.x.y', 'org-1')
     expect(features.brandingRemoved).toBe(true)
     expect(features.customPlatformName).toBe(true)
+    expect(mockSelfHeal).not.toHaveBeenCalled()
   })
 
-  it('does not unlock branding when license is expired', async () => {
-    const past = new Date(Date.now() - 86400000).toISOString()
+  it('ignores the legacy license.valid and license.expiresAt rows', async () => {
+    // These are what a self-hoster can edit with one UPDATE. They must not be
+    // read at all, so a forged row is not even a partial input.
+    const future = new Date(Date.now() + 86400000).toISOString()
     mockFindMany.mockResolvedValue([
       { key: 'license.valid', value: 'true' },
-      { key: 'license.expiresAt', value: past },
+      { key: 'license.expiresAt', value: future },
     ] as any)
     const features = await getFeatures('org-1')
     expect(features.brandingRemoved).toBe(false)
+    const where = mockFindMany.mock.calls[0][0]?.where as { key: { in: string[] } }
+    expect(where.key.in).not.toContain('license.valid')
+    expect(where.key.in).not.toContain('license.expiresAt')
   })
 
-  it('does not unlock branding when license.valid is false', async () => {
-    mockFindMany.mockResolvedValue([{ key: 'license.valid', value: 'false' }] as any)
+  it.each([
+    'expired',
+    'stale',
+    'invalid',
+  ] as const)('keeps branding when the token is %s', async (status) => {
+    mockFindMany.mockResolvedValue([
+      { key: 'license.key', value: 'KEY' },
+      { key: 'license.token', value: 'tvl1.x.y' },
+    ] as any)
+    mockVerify.mockReturnValue(verification(status))
     const features = await getFeatures('org-1')
     expect(features.brandingRemoved).toBe(false)
   })
+
+  it('schedules a background refresh when a key is stored without a usable token', async () => {
+    mockFindMany.mockResolvedValue([{ key: 'license.key', value: 'KEY' }] as any)
+    mockVerify.mockReturnValue(verification('missing'))
+    await getFeatures('org-1')
+    expect(mockSelfHeal).toHaveBeenCalledWith('org-1', 'KEY')
+  })
+
+  it('does not schedule a refresh without a key', async () => {
+    mockFindMany.mockResolvedValue([{ key: 'license.token', value: 'tvl1.x.y' }] as any)
+    mockVerify.mockReturnValue(verification('stale'))
+    await getFeatures('org-1')
+    expect(mockSelfHeal).not.toHaveBeenCalled()
+  })
 })

+ 120 - 93
src/__tests__/lib/license-expiry.test.ts

@@ -28,10 +28,30 @@ vi.mock('@/lib/notification-bus', () => ({
   notificationBus: { emit: vi.fn() },
 }))
 
+vi.mock('@/lib/license/revalidate', () => ({
+  revalidateLicense: vi.fn(),
+}))
+
+vi.mock('@/lib/license/token', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/license/token')>()
+  return { ...actual, verifyLicenseToken: vi.fn() }
+})
+
 import { db } from '@/lib/db'
 import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { notify } from '@/lib/notify'
-import { revalidateOrganizationLicense, sendExpiryWarning } from '@/lib/cron/check-licenses'
+import { revalidateLicense } from '@/lib/license/revalidate'
+import {
+  LICENSE_TOKEN_MAX_AGE_DAYS,
+  LICENSE_TOKEN_WARN_AGE_DAYS,
+  verifyLicenseToken,
+  type LicenseTokenVerification,
+} from '@/lib/license/token'
+import {
+  refreshLicensesMissingTokens,
+  revalidateOrganizationLicense,
+  sendExpiryWarning,
+} from '@/lib/cron/check-licenses'
 
 const ORG_ID = 'org-test-1'
 const USER_ID = 'user-test-1'
@@ -185,145 +205,152 @@ describe('sendExpiryWarning', () => {
 })
 
 describe('revalidateOrganizationLicense', () => {
-  function mockFetch(response: { ok: boolean; data?: Record<string, unknown> }) {
-    global.fetch = vi.fn().mockResolvedValue({
-      ok: response.ok,
-      json: () => Promise.resolve(response.data || {}),
+  function mockRemote(verification: Partial<LicenseTokenVerification>) {
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: {
+        reachable: true,
+        valid: true,
+        plan: 'white-label',
+        expiresAt: '',
+        token: 'tvl1.x.y',
+      },
+      verification: {
+        status: 'valid',
+        payload: null,
+        ageDays: 0,
+        daysUntilExpiry: 100,
+        ...verification,
+      },
     })
   }
 
-  it('stores license data and sends expiry warning when within 14 days', async () => {
-    const expiresAt = daysFromNow(10)
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt },
-    })
+  beforeEach(() => {
     vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
     vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
       userId: USER_ID,
       user: { email: 'owner@test.com' },
     } as any)
+  })
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+  it('refreshes through the shared revalidation and warns when expiry is within 14 days', async () => {
+    mockRemote({ daysUntilExpiry: 10 })
 
-    // Should store license settings
-    expect(db.$transaction).toHaveBeenCalled()
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    // Should trigger expiry warning
+    expect(revalidateLicense).toHaveBeenCalledWith(ORG_ID, LICENSE_KEY)
     expect(notify).toHaveBeenCalledWith(
-      expect.objectContaining({
-        type: 'license_expiring',
-        organizationId: ORG_ID,
-      })
+      expect.objectContaining({ type: 'license_expiring', organizationId: ORG_ID })
     )
   })
 
-  it('does not send warning when expiry is more than 14 days away', async () => {
-    const expiresAt = daysFromNow(30)
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt },
-    })
-
+  it('does not warn when expiry is more than 14 days away', async () => {
+    mockRemote({ daysUntilExpiry: 30 })
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
-
-    expect(db.$transaction).toHaveBeenCalled()
     expect(notify).not.toHaveBeenCalled()
   })
 
-  it('does not send warning when license is invalid', async () => {
-    const expiresAt = daysFromNow(5)
-    mockFetch({
-      ok: true,
-      data: { valid: false, expiresAt },
-    })
-
+  it('does not warn about expiry when the token is not valid', async () => {
+    mockRemote({ status: 'invalid', daysUntilExpiry: 5 })
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
-
     expect(notify).not.toHaveBeenCalled()
   })
 
-  it('does not send warning when license is already expired', async () => {
-    const expiresAt = daysFromNow(-1)
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt },
-    })
-
+  it('does not warn about expiry once already expired', async () => {
+    mockRemote({ status: 'expired', daysUntilExpiry: -1 })
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
-
     expect(notify).not.toHaveBeenCalled()
   })
 
-  it('does not send warning when no expiresAt', async () => {
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro' },
-    })
-
+  it('warns at exactly 14 days and at 1 day', async () => {
+    mockRemote({ daysUntilExpiry: 14 })
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+    expect(notify).toHaveBeenCalledTimes(1)
 
-    expect(notify).not.toHaveBeenCalled()
+    vi.mocked(notify).mockClear()
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
+    mockRemote({ daysUntilExpiry: 1 })
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({ title: 'License expires in 1 day' })
+    )
   })
 
-  it('handles API failure gracefully', async () => {
-    mockFetch({ ok: false })
+  it('warns when the token has not been refreshed for a week', async () => {
+    mockRemote({ ageDays: LICENSE_TOKEN_WARN_AGE_DAYS })
 
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    // Should still store the result (valid=false)
-    expect(db.$transaction).toHaveBeenCalled()
-    expect(notify).not.toHaveBeenCalled()
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        type: 'license_unverified',
+        title: `License could not be verified, branding returns in ${
+          LICENSE_TOKEN_MAX_AGE_DAYS - LICENSE_TOKEN_WARN_AGE_DAYS
+        } days`,
+        entityUrl: '/settings/license',
+      })
+    )
+    expect(sendOrgMail).toHaveBeenCalledWith(
+      ORG_ID,
+      expect.objectContaining({ to: 'owner@test.com' })
+    )
   })
 
-  it('skips if no org member found', async () => {
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt: daysFromNow(5) },
-    })
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null)
+  it('says branding has returned once the token is stale', async () => {
+    mockRemote({ status: 'stale', ageDays: LICENSE_TOKEN_MAX_AGE_DAYS + 3 })
 
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
 
-    expect(db.$transaction).not.toHaveBeenCalled()
-    expect(notify).not.toHaveBeenCalled()
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        type: 'license_unverified',
+        title: 'License could not be verified, branding has returned',
+      })
+    )
   })
 
-  it('sends warning at exactly 14 days', async () => {
-    const expiresAt = daysFromNow(14)
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt },
-    })
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      userId: USER_ID,
-      user: { email: 'owner@test.com' },
-    } as any)
-
+  it('stays quiet about verification while the token is fresh', async () => {
+    mockRemote({ ageDays: 1 })
     await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+    expect(notify).not.toHaveBeenCalled()
+  })
 
-    expect(notify).toHaveBeenCalled()
+  it('sends the verification warning once per day', async () => {
+    const today = new Date().toISOString().slice(0, 10)
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: today } as any)
+    mockRemote({ ageDays: 10 })
+    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+    expect(notify).not.toHaveBeenCalled()
   })
+})
 
-  it('sends warning at exactly 1 day', async () => {
-    const expiresAt = daysFromNow(1)
-    mockFetch({
-      ok: true,
-      data: { valid: true, plan: 'pro', expiresAt },
+describe('refreshLicensesMissingTokens', () => {
+  it('refreshes only orgs whose stored token does not verify', async () => {
+    vi.mocked(db.appSetting.findMany)
+      .mockResolvedValueOnce([
+        { organizationId: 'org-fresh', value: 'KEY-A' },
+        { organizationId: 'org-legacy', value: 'KEY-B' },
+        { organizationId: null, value: 'KEY-C' },
+      ] as any)
+      .mockResolvedValueOnce([{ organizationId: 'org-fresh', value: 'tvl1.good' }] as any)
+    vi.mocked(verifyLicenseToken).mockImplementation((token) => ({
+      status: token === 'tvl1.good' ? 'valid' : 'missing',
+      payload: null,
+      ageDays: null,
+      daysUntilExpiry: null,
+    }))
+    vi.mocked(revalidateLicense).mockResolvedValue({
+      remote: { reachable: true, valid: true, plan: 'white-label', expiresAt: '', token: 't' },
+      verification: { status: 'valid', payload: null, ageDays: 0, daysUntilExpiry: 100 },
     })
-    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null)
-    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
-      userId: USER_ID,
-      user: { email: 'owner@test.com' },
-    } as any)
 
-    await revalidateOrganizationLicense(ORG_ID, LICENSE_KEY)
+    await refreshLicensesMissingTokens()
 
-    expect(notify).toHaveBeenCalledWith(
-      expect.objectContaining({
-        title: 'License expires in 1 day',
-      })
-    )
+    expect(revalidateLicense).toHaveBeenCalledTimes(1)
+    expect(revalidateLicense).toHaveBeenCalledWith('org-legacy', 'KEY-B')
+  })
+
+  it('never throws', async () => {
+    vi.mocked(db.appSetting.findMany).mockRejectedValue(new Error('db down'))
+    await expect(refreshLicensesMissingTokens()).resolves.toBeUndefined()
   })
 })

+ 131 - 0
src/__tests__/lib/license-token.test.ts

@@ -0,0 +1,131 @@
+import { describe, it, expect } from 'vitest'
+import { generateKeyPairSync, sign } from 'node:crypto'
+import {
+  LICENSE_TOKEN_MAX_AGE_DAYS,
+  verifyLicenseToken,
+  verifyLicenseTokenWith,
+} from '@/lib/license/token'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const ORG = 'org-abc'
+
+const { privateKey, publicKey } = generateKeyPairSync('ed25519')
+const other = generateKeyPairSync('ed25519')
+
+function mint(
+  overrides: Partial<Record<string, unknown>> = {},
+  key = privateKey,
+  prefix = 'tvl1'
+): string {
+  const payload = {
+    v: 1,
+    lid: 'lic-1',
+    org: ORG,
+    plan: 'white-label',
+    expiresAt: new Date(Date.now() + 200 * DAY_MS).toISOString(),
+    issuedAt: new Date().toISOString(),
+    ...overrides,
+  }
+  const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
+  const sig = sign(null, Buffer.from(encoded), key).toString('base64url')
+  return `${prefix}.${encoded}.${sig}`
+}
+
+const verify = (token: string | null | undefined, now?: Date) =>
+  verifyLicenseTokenWith([publicKey], token, ORG, now)
+
+describe('verifyLicenseToken', () => {
+  it('accepts a fresh token signed for this org', () => {
+    const v = verify(mint())
+    expect(v.status).toBe('valid')
+    expect(v.payload?.plan).toBe('white-label')
+    expect(v.ageDays).toBe(0)
+    expect(v.daysUntilExpiry).toBe(200)
+  })
+
+  it('reports missing when there is no token', () => {
+    expect(verify(null).status).toBe('missing')
+    expect(verify('').status).toBe('missing')
+    expect(verify(undefined).status).toBe('missing')
+  })
+
+  it('rejects a token signed with a different key', () => {
+    expect(verify(mint({}, other.privateKey)).status).toBe('invalid')
+  })
+
+  it('rejects a token minted for another organization', () => {
+    expect(verify(mint({ org: 'someone-else' })).status).toBe('invalid')
+  })
+
+  it('rejects a payload that was edited after signing', () => {
+    const token = mint({ expiresAt: new Date(Date.now() - DAY_MS).toISOString() })
+    const [prefix, , sig] = token.split('.')
+    const forged = Buffer.from(
+      JSON.stringify({
+        v: 1,
+        lid: 'lic-1',
+        org: ORG,
+        plan: 'white-label',
+        expiresAt: new Date(Date.now() + 999 * DAY_MS).toISOString(),
+        issuedAt: new Date().toISOString(),
+      })
+    ).toString('base64url')
+    expect(verify(`${prefix}.${forged}.${sig}`).status).toBe('invalid')
+  })
+
+  it('rejects garbage and wrong prefixes', () => {
+    expect(verify('not-a-token').status).toBe('invalid')
+    expect(verify('a.b.c').status).toBe('invalid')
+    expect(verify(mint({}, privateKey, 'tvl9')).status).toBe('invalid')
+  })
+
+  it('rejects a well-signed payload with the wrong shape', () => {
+    expect(verify(mint({ v: 2 })).status).toBe('invalid')
+    expect(verify(mint({ plan: '' })).status).toBe('invalid')
+    expect(verify(mint({ issuedAt: 'yesterday' })).status).toBe('invalid')
+  })
+
+  it('reports expired once the licence term is over, keeping the payload', () => {
+    const v = verify(mint({ expiresAt: new Date(Date.now() - DAY_MS).toISOString() }))
+    expect(v.status).toBe('expired')
+    expect(v.payload).not.toBeNull()
+    expect(v.daysUntilExpiry).toBeLessThanOrEqual(0)
+  })
+
+  it('reports stale when the token has not been refreshed within the ceiling', () => {
+    const issuedAt = new Date(Date.now() - (LICENSE_TOKEN_MAX_AGE_DAYS + 1) * DAY_MS)
+    const v = verify(mint({ issuedAt: issuedAt.toISOString() }))
+    expect(v.status).toBe('stale')
+    expect(v.ageDays).toBe(LICENSE_TOKEN_MAX_AGE_DAYS + 1)
+  })
+
+  it('is still valid on the last day inside the ceiling', () => {
+    const issuedAt = new Date(Date.now() - (LICENSE_TOKEN_MAX_AGE_DAYS * DAY_MS - 60_000))
+    expect(verify(mint({ issuedAt: issuedAt.toISOString() })).status).toBe('valid')
+  })
+
+  it('treats a token from the future as fresh rather than forged', () => {
+    const issuedAt = new Date(Date.now() + 2 * DAY_MS)
+    expect(verify(mint({ issuedAt: issuedAt.toISOString() })).status).toBe('valid')
+  })
+
+  it('expiry wins over staleness', () => {
+    const v = verify(
+      mint({
+        issuedAt: new Date(Date.now() - 30 * DAY_MS).toISOString(),
+        expiresAt: new Date(Date.now() - DAY_MS).toISOString(),
+      })
+    )
+    expect(v.status).toBe('expired')
+  })
+
+  it('honours an explicit clock', () => {
+    const token = mint()
+    const later = new Date(Date.now() + (LICENSE_TOKEN_MAX_AGE_DAYS + 5) * DAY_MS)
+    expect(verify(token, later).status).toBe('stale')
+  })
+
+  it('the production verifier does not accept a token from a key it does not embed', () => {
+    expect(verifyLicenseToken(mint(), ORG).status).toBe('invalid')
+  })
+})

+ 29 - 12
src/app/(authenticated)/layout.tsx

@@ -25,6 +25,11 @@ import { SupportBubble } from '@/features/support/Components/SupportBubble'
 import { isSupportEnabled } from '@/lib/support'
 import { ServiceTypeProvider } from '@/components/service-type-context'
 import { LicenseExpiryProvider } from '@/components/license-expiry-context'
+import {
+  LICENSE_TOKEN_MAX_AGE_DAYS,
+  LICENSE_TOKEN_WARN_AGE_DAYS,
+  verifyLicenseToken,
+} from '@/lib/license/token'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
 import { isTireHotelEnabled } from '@/features/tire-hotel/Lib/tireHotelSettings'
@@ -215,30 +220,42 @@ export default async function DashboardLayout({ children }: { children: React.Re
     ? (getManifest(lookupConnection.connectorId)?.name ?? null)
     : null
 
-  // Check license expiry (only for admin/owner with white-label)
+  // Licence notices for admins and owners. Both clocks come off the signed
+  // token: the term itself, and how long since torqvoice.com last confirmed
+  // it. An install with a key but nothing verifiable is told so, too.
   let daysUntilExpiry: number | null = null
+  let unverifiedDaysLeft: number | null = null
   let licenseExpiryDismissed = false
-  if (isOwnerOrAdmin && features.brandingRemoved && !isCloudMode()) {
-    const expirySettings = await db.appSetting.findMany({
+  if (isOwnerOrAdmin && !isCloudMode()) {
+    const licenceSettings = await db.appSetting.findMany({
       where: {
         organizationId: data.organizationId,
-        key: { in: ['license.expiresAt', 'license.valid', 'license.expiryDismissed'] },
+        key: { in: ['license.token', 'license.key', 'license.expiryDismissed'] },
       },
       select: { key: true, value: true },
     })
-    const expiryMap = new Map(expirySettings.map((s) => [s.key, s.value]))
-    const expiresAt = expiryMap.get('license.expiresAt')
-    const isValid = expiryMap.get('license.valid')
-    licenseExpiryDismissed = expiryMap.get('license.expiryDismissed') === 'true'
-    if (expiresAt && isValid === 'true') {
-      const diff = new Date(expiresAt).getTime() - Date.now()
-      daysUntilExpiry = Math.ceil(diff / (1000 * 60 * 60 * 24))
+    const licenceMap = new Map(licenceSettings.map((s) => [s.key, s.value]))
+    licenseExpiryDismissed = licenceMap.get('license.expiryDismissed') === 'true'
+    const verification = verifyLicenseToken(licenceMap.get('license.token'), data.organizationId)
+    if (verification.status === 'valid') {
+      daysUntilExpiry = verification.daysUntilExpiry
+      if (verification.ageDays !== null && verification.ageDays >= LICENSE_TOKEN_WARN_AGE_DAYS) {
+        unverifiedDaysLeft = Math.max(0, LICENSE_TOKEN_MAX_AGE_DAYS - verification.ageDays)
+      }
+    } else if (verification.status === 'stale') {
+      unverifiedDaysLeft = 0
+    } else if (verification.status === 'expired') {
+      daysUntilExpiry = verification.daysUntilExpiry
     }
   }
 
   return (
     <ServiceTypeProvider serviceType={data.serviceType}>
-      <LicenseExpiryProvider daysUntilExpiry={daysUntilExpiry} dismissed={licenseExpiryDismissed}>
+      <LicenseExpiryProvider
+        daysUntilExpiry={daysUntilExpiry}
+        unverifiedDaysLeft={unverifiedDaysLeft}
+        dismissed={licenseExpiryDismissed}
+      >
         <WhiteLabelCtaProvider show={showWhiteLabelCta}>
           {/* Accent line along the very top of the viewport — the card hairline at
         page scale: primary on the left, gone by the far edge. Marks where the

+ 10 - 7
src/app/(authenticated)/settings/license/page.tsx

@@ -5,6 +5,7 @@ import { isCloudMode } from '@/lib/features'
 import { LicenseSettings } from '@/features/settings/Components/license-settings'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { isDemoMode } from '@/lib/demo'
+import { verifyLicenseToken } from '@/lib/license/token'
 
 export default async function LicensePage() {
   if (isCloudMode()) {
@@ -18,23 +19,25 @@ export default async function LicensePage() {
     where: {
       organizationId: authContext.organizationId,
       key: {
-        in: [
-          SETTING_KEYS.LICENSE_KEY,
-          SETTING_KEYS.LICENSE_VALID,
-          SETTING_KEYS.LICENSE_CHECKED_AT,
-          SETTING_KEYS.LICENSE_PLAN,
-        ],
+        in: [SETTING_KEYS.LICENSE_KEY, SETTING_KEYS.LICENSE_TOKEN, SETTING_KEYS.LICENSE_CHECKED_AT],
       },
     },
     select: { key: true, value: true },
   })
 
   const map = new Map(settings.map((s) => [s.key, s.value]))
+  // What the feature gate sees, not what the last validate call said.
+  const verification = verifyLicenseToken(
+    map.get(SETTING_KEYS.LICENSE_TOKEN),
+    authContext.organizationId
+  )
 
   return (
     <LicenseSettings
       initialKey={map.get(SETTING_KEYS.LICENSE_KEY) || ''}
-      initialValid={map.get(SETTING_KEYS.LICENSE_VALID) === 'true'}
+      initialStatus={verification.status}
+      initialExpiresAt={verification.payload?.expiresAt ?? ''}
+      initialIssuedAt={verification.payload?.issuedAt ?? ''}
       initialCheckedAt={map.get(SETTING_KEYS.LICENSE_CHECKED_AT) || ''}
       demoMode={isDemoMode}
     />

+ 12 - 1
src/components/license-expiry-context.tsx

@@ -5,12 +5,19 @@ import { dismissLicenseExpiryBanner } from '@/features/settings/Actions/settings
 
 interface LicenseExpiryInfo {
   daysUntilExpiry: number | null
+  /**
+   * Days until an unrefreshed licence token is no longer trusted, once the
+   * warning threshold is crossed. 0 means branding has already returned.
+   * null when the token is fresh or there is no licence at all.
+   */
+  unverifiedDaysLeft: number | null
   dismissed: boolean
   dismiss: () => void
 }
 
 const LicenseExpiryContext = createContext<LicenseExpiryInfo>({
   daysUntilExpiry: null,
+  unverifiedDaysLeft: null,
   dismissed: false,
   // biome-ignore lint/suspicious/noEmptyBlockStatements: default noop
   dismiss: () => {},
@@ -18,10 +25,12 @@ const LicenseExpiryContext = createContext<LicenseExpiryInfo>({
 
 export function LicenseExpiryProvider({
   daysUntilExpiry,
+  unverifiedDaysLeft,
   dismissed: initialDismissed,
   children,
 }: {
   daysUntilExpiry: number | null
+  unverifiedDaysLeft: number | null
   dismissed: boolean
   children: React.ReactNode
 }) {
@@ -33,7 +42,9 @@ export function LicenseExpiryProvider({
   }
 
   return (
-    <LicenseExpiryContext.Provider value={{ daysUntilExpiry, dismissed, dismiss }}>
+    <LicenseExpiryContext.Provider
+      value={{ daysUntilExpiry, unverifiedDaysLeft, dismissed, dismiss }}
+    >
       {children}
     </LicenseExpiryContext.Provider>
   )

+ 28 - 4
src/components/page-header.tsx

@@ -195,13 +195,16 @@ const marineBreadcrumbKeys: Record<string, string> = {
 export function PageHeader() {
   const pathname = usePathname()
   const showWhiteLabelCta = useShowWhiteLabelCta()
-  const { daysUntilExpiry, dismissed, dismiss } = useLicenseExpiry()
+  const { daysUntilExpiry, unverifiedDaysLeft, dismissed, dismiss } = useLicenseExpiry()
   // Weeks of warning before a licence lapses, so this waits behind anything
-  // happening right now rather than adding a second bar beneath it.
+  // happening right now rather than adding a second bar beneath it. A licence
+  // that torqvoice.com has not confirmed for a week takes the same slot and
+  // is not dismissable: the fix is on the operator's side of the network.
+  const showUnverifiedNotice = unverifiedDaysLeft !== null
   const showLicenceNotice = useBannerSlot(
     'licence',
     BANNER_PRIORITY.licence,
-    daysUntilExpiry !== null && daysUntilExpiry <= 14 && !dismissed
+    showUnverifiedNotice || (daysUntilExpiry !== null && daysUntilExpiry <= 14 && !dismissed)
   )
   const t = useTranslations('navigation.breadcrumbs')
   const tn = useTranslations('navigation')
@@ -317,7 +320,28 @@ export function PageHeader() {
           )}
         </div>
       </header>
-      {showLicenceNotice && daysUntilExpiry !== null && (
+      {showLicenceNotice && showUnverifiedNotice && (
+        <div
+          className={`flex items-center gap-2 px-4 py-2 text-sm ${
+            unverifiedDaysLeft <= 0
+              ? 'bg-destructive/10 text-destructive border-b border-destructive/20'
+              : 'bg-amber-500/10 text-amber-600 border-b border-amber-500/20'
+          }`}
+        >
+          <AlertTriangle className="h-4 w-4 shrink-0" />
+          <span>
+            {unverifiedDaysLeft <= 0
+              ? tn('licenseUnverifiedNow')
+              : tn('licenseUnverifiedDays', { days: unverifiedDaysLeft })}
+          </span>
+          <div className="ml-auto flex items-center gap-2 shrink-0">
+            <Link href="/settings/license" className="font-medium underline hover:no-underline">
+              {tn('licenseVerify')}
+            </Link>
+          </div>
+        </div>
+      )}
+      {showLicenceNotice && !showUnverifiedNotice && daysUntilExpiry !== null && (
         <div
           className={`flex items-center gap-2 px-4 py-2 text-sm ${
             daysUntilExpiry <= 0

+ 27 - 146
src/features/settings/Actions/validateLicense.ts

@@ -1,15 +1,27 @@
 'use server'
 
 import { withAuth } from '@/lib/with-auth'
-import { db } from '@/lib/db'
-import { SETTING_KEYS } from '../Schema/settingsSchema'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
 import { demoGuard } from '@/lib/demo'
+import { db } from '@/lib/db'
+import { revalidateLicense } from '@/lib/license/revalidate'
+import type { LicenseTokenStatus } from '@/lib/license/token'
+
+export type ValidateLicenseResult = {
+  /** what the feature gate now sees */
+  status: LicenseTokenStatus
+  valid: boolean
+  plan: string
+  /** false when torqvoice.com could not be reached; the stored token is untouched */
+  reachable: boolean
+  /** torqvoice.com's reason when it answered "not valid" */
+  reason?: string
+}
 
 export async function validateLicense(licenseKey: string) {
   return withAuth(
-    async (ctx) => {
+    async (ctx): Promise<ValidateLicenseResult> => {
       // The demo runs on our licence, not the visitor's, and activating one
       // here would move a real customer's plan onto a shared instance.
       demoGuard()
@@ -18,153 +30,22 @@ export async function validateLicense(licenseKey: string) {
         throw new Error('License key is required')
       }
 
-      let valid = false
-      let plan = 'free'
-      let expiresAt = ''
-
-      try {
-        const response = await fetch(
-          `${process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'}/api/license/validate`,
-          {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ key, organizationId: ctx.organizationId }),
-            signal: AbortSignal.timeout(10000),
-          }
-        )
-
-        if (response.ok) {
-          const data = await response.json()
-          valid = data.valid === true
-          if (valid && data.plan) {
-            plan = data.plan
-          }
-          if (data.expiresAt) {
-            expiresAt = data.expiresAt
-          }
-        }
-      } catch {
-        // API unreachable — fall back to cached result
-        const cached = await db.appSetting.findMany({
-          where: {
-            organizationId: ctx.organizationId,
-            key: {
-              in: [
-                SETTING_KEYS.LICENSE_VALID,
-                SETTING_KEYS.LICENSE_PLAN,
-                SETTING_KEYS.LICENSE_EXPIRES_AT,
-              ],
-            },
-          },
-          select: { key: true, value: true },
-        })
-        for (const setting of cached) {
-          if (setting.key === SETTING_KEYS.LICENSE_VALID) {
-            valid = setting.value === 'true'
-          }
-          if (setting.key === SETTING_KEYS.LICENSE_PLAN) {
-            plan = setting.value
-          }
-          if (setting.key === SETTING_KEYS.LICENSE_EXPIRES_AT) {
-            expiresAt = setting.value
-          }
-        }
-      }
-
-      const now = new Date().toISOString()
+      const { remote, verification } = await revalidateLicense(ctx.organizationId, key, ctx.userId)
 
-      await db.$transaction([
-        db.appSetting.upsert({
-          where: {
-            organizationId_key: {
-              organizationId: ctx.organizationId,
-              key: SETTING_KEYS.LICENSE_KEY,
-            },
-          },
-          update: { value: key },
-          create: {
-            userId: ctx.userId,
-            organizationId: ctx.organizationId,
-            key: SETTING_KEYS.LICENSE_KEY,
-            value: key,
-          },
-        }),
-        db.appSetting.upsert({
-          where: {
-            organizationId_key: {
-              organizationId: ctx.organizationId,
-              key: SETTING_KEYS.LICENSE_VALID,
-            },
-          },
-          update: { value: String(valid) },
-          create: {
-            userId: ctx.userId,
-            organizationId: ctx.organizationId,
-            key: SETTING_KEYS.LICENSE_VALID,
-            value: String(valid),
-          },
-        }),
-        db.appSetting.upsert({
-          where: {
-            organizationId_key: {
-              organizationId: ctx.organizationId,
-              key: SETTING_KEYS.LICENSE_CHECKED_AT,
-            },
-          },
-          update: { value: now },
-          create: {
-            userId: ctx.userId,
-            organizationId: ctx.organizationId,
-            key: SETTING_KEYS.LICENSE_CHECKED_AT,
-            value: now,
-          },
-        }),
-        db.appSetting.upsert({
-          where: {
-            organizationId_key: {
-              organizationId: ctx.organizationId,
-              key: SETTING_KEYS.LICENSE_PLAN,
-            },
-          },
-          update: { value: plan },
-          create: {
-            userId: ctx.userId,
-            organizationId: ctx.organizationId,
-            key: SETTING_KEYS.LICENSE_PLAN,
-            value: plan,
-          },
-        }),
-        ...(expiresAt
-          ? [
-              db.appSetting.upsert({
-                where: {
-                  organizationId_key: {
-                    organizationId: ctx.organizationId,
-                    key: SETTING_KEYS.LICENSE_EXPIRES_AT,
-                  },
-                },
-                update: { value: expiresAt },
-                create: {
-                  userId: ctx.userId,
-                  organizationId: ctx.organizationId,
-                  key: SETTING_KEYS.LICENSE_EXPIRES_AT,
-                  value: expiresAt,
-                },
-              }),
-            ]
-          : []),
-        // Reset expiry dismissed flag so banner can warn again on next cycle
-        db.appSetting.deleteMany({
-          where: {
-            organizationId: ctx.organizationId,
-            key: 'license.expiryDismissed',
-          },
-        }),
-      ])
+      // Let the expiry banner warn again on the next cycle.
+      await db.appSetting.deleteMany({
+        where: { organizationId: ctx.organizationId, key: 'license.expiryDismissed' },
+      })
 
       revalidatePath('/settings')
 
-      return { valid, plan }
+      return {
+        status: verification.status,
+        valid: verification.status === 'valid',
+        plan: verification.payload?.plan ?? remote.plan,
+        reachable: remote.reachable,
+        reason: remote.error,
+      }
     },
     {
       requiredPermissions: [

+ 57 - 10
src/features/settings/Components/license-settings.tsx

@@ -11,32 +11,59 @@ import { Badge } from '@/components/ui/badge'
 import { AppCard } from '@/components/app-card'
 import { ExternalLink, Key, Loader2 } from 'lucide-react'
 import { validateLicense } from '../Actions/validateLicense'
+import {
+  LICENSE_TOKEN_MAX_AGE_DAYS,
+  LICENSE_TOKEN_WARN_AGE_DAYS,
+  type LicenseTokenStatus,
+} from '@/lib/license/token'
+
+const DAY_MS = 24 * 60 * 60 * 1000
 
 export function LicenseSettings({
   initialKey,
-  initialValid,
+  initialStatus,
+  initialExpiresAt,
+  initialIssuedAt,
   initialCheckedAt,
   demoMode = false,
 }: {
   initialKey: string
-  initialValid: boolean
+  initialStatus: LicenseTokenStatus
+  /** from the verified token; empty without one */
+  initialExpiresAt: string
+  /** when torqvoice.com last signed the stored token; empty without one */
+  initialIssuedAt: string
+  /** when this server last tried, reachable or not */
   initialCheckedAt: string
   demoMode?: boolean
 }) {
   const router = useRouter()
   const t = useTranslations('settings')
   const [licenseKey, setLicenseKey] = useState(initialKey)
-  const [licenseValid, setLicenseValid] = useState(initialValid)
+  const [status, setStatus] = useState<LicenseTokenStatus>(initialStatus)
   const [isValidating, setIsValidating] = useState(false)
 
+  const licenseValid = status === 'valid'
+  const issuedAgeDays = initialIssuedAt
+    ? Math.floor((Date.now() - new Date(initialIssuedAt).getTime()) / DAY_MS)
+    : null
+  const showUnverifiedHint =
+    (status === 'valid' || status === 'stale') &&
+    issuedAgeDays !== null &&
+    issuedAgeDays >= LICENSE_TOKEN_WARN_AGE_DAYS
+
   const handleValidateLicense = async () => {
     setIsValidating(true)
     try {
       const result = await validateLicense(licenseKey)
       if (result.success && result.data) {
-        setLicenseValid(result.data.valid)
-        if (result.data.valid) {
+        setStatus(result.data.status)
+        if (!result.data.reachable) {
+          toast.error(t('license.unreachable'))
+        } else if (result.data.valid) {
           toast.success(t('license.validated'))
+        } else if (result.data.reason) {
+          toast.error(t('license.rejected', { reason: result.data.reason }))
         } else {
           toast.error(t('license.invalid'))
         }
@@ -49,6 +76,16 @@ export function LicenseSettings({
     }
   }
 
+  const statusBadge = licenseValid ? (
+    <Badge variant="default">{t('license.active')}</Badge>
+  ) : status === 'expired' ? (
+    <Badge variant="destructive">{t('license.expired')}</Badge>
+  ) : status === 'stale' ? (
+    <Badge variant="outline">{t('license.unverified')}</Badge>
+  ) : (
+    <Badge variant="secondary">{t('license.inactive')}</Badge>
+  )
+
   return (
     <div className="space-y-6">
       <AppCard
@@ -71,12 +108,13 @@ export function LicenseSettings({
         }
         contentClassName="space-y-4"
       >
-        <div className="flex items-center gap-2">
+        <div className="flex flex-wrap items-center gap-2">
           <Label>{t('license.status')}</Label>
-          {licenseValid ? (
-            <Badge variant="default">{t('license.active')}</Badge>
-          ) : (
-            <Badge variant="secondary">{t('license.inactive')}</Badge>
+          {statusBadge}
+          {initialExpiresAt && (
+            <span className="text-xs text-muted-foreground">
+              {t('license.expiresOn', { date: new Date(initialExpiresAt).toLocaleDateString() })}
+            </span>
           )}
           {initialCheckedAt && (
             <span className="text-xs text-muted-foreground">
@@ -84,6 +122,14 @@ export function LicenseSettings({
             </span>
           )}
         </div>
+        {showUnverifiedHint && issuedAgeDays !== null && (
+          <p className="text-xs text-amber-600">
+            {t('license.unverifiedHint', {
+              days: issuedAgeDays,
+              max: LICENSE_TOKEN_MAX_AGE_DAYS,
+            })}
+          </p>
+        )}
         <div className="flex gap-2">
           <Input
             placeholder={t('license.enterLicenseKey')}
@@ -105,6 +151,7 @@ export function LicenseSettings({
           </Button>
         </div>
         <p className="text-xs text-muted-foreground">{t('license.keyHint')}</p>
+        <p className="text-xs text-muted-foreground">{t('license.signedHint')}</p>
       </AppCard>
     </div>
   )

+ 3 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -89,6 +89,9 @@ export const SETTING_KEYS = {
   PAYMENT_TERMS_OF_SALE: 'payment.termsOfSale',
   PAYMENT_TERMS_OF_SALE_URL: 'payment.termsOfSaleUrl',
   LICENSE_KEY: 'license.key',
+  /// The signed token from torqvoice.com. The only licence row the feature
+  /// gate reads; everything else under license.* is a display cache.
+  LICENSE_TOKEN: 'license.token',
   LICENSE_VALID: 'license.valid',
   LICENSE_CHECKED_AT: 'license.checkedAt',
   LICENSE_PLAN: 'license.plan',

+ 150 - 122
src/lib/cron/check-licenses.ts

@@ -2,119 +2,89 @@ import { CronJob } from 'cron'
 import { db } from '@/lib/db'
 import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { notify } from '@/lib/notify'
+import { revalidateLicense } from '@/lib/license/revalidate'
+import {
+  LICENSE_TOKEN_MAX_AGE_DAYS,
+  LICENSE_TOKEN_WARN_AGE_DAYS,
+  verifyLicenseToken,
+} from '@/lib/license/token'
 
-const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
 const EXPIRY_WARNING_DAYS = 14
 
+/**
+ * Re-checks one org's key against torqvoice.com and stores the signed token
+ * that comes back. Then warns about whichever clock is running out: the
+ * licence term, or the token's age when torqvoice.com has been unreachable.
+ */
 export async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
-  let valid = false
-  let plan = 'free'
-  let expiresAt = ''
-
-  const response = await fetch(`${TORQVOICE_COM_URL}/api/license/validate`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ key: licenseKey, organizationId }),
-    signal: AbortSignal.timeout(10000),
-  })
-
-  if (response.ok) {
-    const data = await response.json()
-    valid = data.valid === true
-    if (valid && data.plan) plan = data.plan
-    if (data.expiresAt) expiresAt = data.expiresAt
+  const { verification } = await revalidateLicense(organizationId, licenseKey)
+  const { status, daysUntilExpiry, ageDays } = verification
+
+  if (
+    status === 'valid' &&
+    daysUntilExpiry !== null &&
+    daysUntilExpiry <= EXPIRY_WARNING_DAYS &&
+    daysUntilExpiry > 0
+  ) {
+    await sendExpiryWarning(organizationId, daysUntilExpiry)
   }
 
-  const now = new Date().toISOString()
-  const orgMember = await db.organizationMember.findFirst({
-    where: { organizationId },
-    select: { userId: true },
-  })
-
-  if (!orgMember) return
-
-  const upserts = [
-    db.appSetting.upsert({
-      where: { organizationId_key: { organizationId, key: 'license.valid' } },
-      update: { value: String(valid) },
-      create: {
-        userId: orgMember.userId,
-        organizationId,
-        key: 'license.valid',
-        value: String(valid),
-      },
-    }),
-    db.appSetting.upsert({
-      where: { organizationId_key: { organizationId, key: 'license.checkedAt' } },
-      update: { value: now },
-      create: { userId: orgMember.userId, organizationId, key: 'license.checkedAt', value: now },
-    }),
-    db.appSetting.upsert({
-      where: { organizationId_key: { organizationId, key: 'license.plan' } },
-      update: { value: plan },
-      create: { userId: orgMember.userId, organizationId, key: 'license.plan', value: plan },
-    }),
-  ]
-
-  if (expiresAt) {
-    upserts.push(
-      db.appSetting.upsert({
-        where: { organizationId_key: { organizationId, key: 'license.expiresAt' } },
-        update: { value: expiresAt },
-        create: {
-          userId: orgMember.userId,
-          organizationId,
-          key: 'license.expiresAt',
-          value: expiresAt,
-        },
-      })
-    )
-  }
-
-  await db.$transaction(upserts)
-
-  // Send expiry warning if within threshold
-  if (valid && expiresAt) {
-    const diff = new Date(expiresAt).getTime() - Date.now()
-    const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
-
-    if (daysLeft <= EXPIRY_WARNING_DAYS && daysLeft > 0) {
-      await sendExpiryWarning(organizationId, daysLeft)
-    }
+  // Signature fine, licence not over, but no fresh token for a week: the
+  // daily refresh has been failing. Say so before the ceiling cuts branding.
+  if (
+    (status === 'valid' || status === 'stale') &&
+    ageDays !== null &&
+    ageDays >= LICENSE_TOKEN_WARN_AGE_DAYS
+  ) {
+    await sendVerificationWarning(organizationId, Math.max(0, LICENSE_TOKEN_MAX_AGE_DAYS - ageDays))
   }
 }
 
-export async function sendExpiryWarning(organizationId: string, daysLeft: number) {
-  // Check if we already warned today
-  const lastWarning = await db.appSetting.findUnique({
-    where: { organizationId_key: { organizationId, key: 'license.lastExpiryWarning' } },
+async function alreadyWarnedToday(organizationId: string, key: string): Promise<boolean> {
+  const last = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key } },
     select: { value: true },
   })
   const today = new Date().toISOString().slice(0, 10)
-  if (lastWarning?.value === today) return
+  if (last?.value === today) return true
 
-  // Record that we warned today
   const orgMember = await db.organizationMember.findFirst({
     where: { organizationId },
     select: { userId: true },
   })
-  if (!orgMember) return
+  if (!orgMember) return true
 
   await db.appSetting.upsert({
-    where: { organizationId_key: { organizationId, key: 'license.lastExpiryWarning' } },
+    where: { organizationId_key: { organizationId, key } },
     update: { value: today },
-    create: {
-      userId: orgMember.userId,
-      organizationId,
-      key: 'license.lastExpiryWarning',
-      value: today,
-    },
+    create: { userId: orgMember.userId, organizationId, key, value: today },
   })
+  return false
+}
+
+async function emailOwner(organizationId: string, subject: string, html: string) {
+  try {
+    const owner = await db.organizationMember.findFirst({
+      where: { organizationId, role: 'owner' },
+      include: { user: { select: { email: true } } },
+    })
+    if (!owner?.user.email) return
+
+    const from = await getOrgFromAddress(organizationId)
+    await sendOrgMail(organizationId, { from, to: owner.user.email, subject, html })
+  } catch (error) {
+    console.warn(`[cron] Failed to send license email for org ${organizationId}:`, error)
+  }
+}
+
+export async function sendExpiryWarning(organizationId: string, daysLeft: number) {
+  if (await alreadyWarnedToday(organizationId, 'license.lastExpiryWarning')) return
+
+  const dayWord = `${daysLeft} day${daysLeft === 1 ? '' : 's'}`
 
-  // In-app notification
   await notify({
     type: 'license_expiring',
-    title: `License expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`,
+    title: `License expires in ${dayWord}`,
     message: 'Please renew your license to maintain full access to all features.',
     entityType: 'license',
     entityId: organizationId,
@@ -122,30 +92,99 @@ export async function sendExpiryWarning(organizationId: string, daysLeft: number
     organizationId,
   })
 
-  // Email notification to org owner
-  try {
-    const owner = await db.organizationMember.findFirst({
-      where: { organizationId, role: 'owner' },
-      include: { user: { select: { email: true } } },
-    })
-    if (!owner?.user.email) return
-
-    const from = await getOrgFromAddress(organizationId)
-    await sendOrgMail(organizationId, {
-      from,
-      to: owner.user.email,
-      subject: `Your license expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`,
-      html: `
+  await emailOwner(
+    organizationId,
+    `Your license expires in ${dayWord}`,
+    `
         <div style="font-family: sans-serif; max-width: 600px;">
           <h2 style="color: #d97706;">License Expiration Notice</h2>
-          <p>Your license will expire in <strong>${daysLeft} day${daysLeft === 1 ? '' : 's'}</strong>.</p>
+          <p>Your license will expire in <strong>${dayWord}</strong>.</p>
           <p>Please renew your license to continue using all features without interruption.</p>
           <p>You can manage your license in <strong>Settings &gt; License</strong>.</p>
         </div>
-      `,
+      `
+  )
+}
+
+export async function sendVerificationWarning(organizationId: string, daysUntilCutoff: number) {
+  if (await alreadyWarnedToday(organizationId, 'license.lastVerificationWarning')) return
+
+  const dayWord = `${daysUntilCutoff} day${daysUntilCutoff === 1 ? '' : 's'}`
+  const title =
+    daysUntilCutoff > 0
+      ? `License could not be verified, branding returns in ${dayWord}`
+      : 'License could not be verified, branding has returned'
+
+  await notify({
+    type: 'license_unverified',
+    title,
+    message:
+      'This server has not been able to reach torqvoice.com to confirm the license. Check outbound access, then open Settings > License and choose Validate.',
+    entityType: 'license',
+    entityId: organizationId,
+    entityUrl: '/settings/license',
+    organizationId,
+  })
+
+  await emailOwner(
+    organizationId,
+    title,
+    `
+        <div style="font-family: sans-serif; max-width: 600px;">
+          <h2 style="color: #d97706;">License Verification Notice</h2>
+          <p>Your Torqvoice server has not been able to reach torqvoice.com to confirm its white-label license for more than ${LICENSE_TOKEN_WARN_AGE_DAYS} days.</p>
+          <p>A license that stays unverified for ${LICENSE_TOKEN_MAX_AGE_DAYS} days is treated as inactive and Torqvoice branding returns until it is verified again. Nothing else changes and no data is affected.</p>
+          <p>Please check that the server can reach <strong>torqvoice.com</strong>, then open <strong>Settings &gt; License</strong> and choose <strong>Validate</strong>.</p>
+        </div>
+      `
+  )
+}
+
+type KeyedOrg = { organizationId: string; key: string }
+
+async function orgsWithKeys(): Promise<KeyedOrg[]> {
+  const rows = await db.appSetting.findMany({
+    where: { key: 'license.key' },
+    select: { organizationId: true, value: true },
+  })
+  return rows
+    .filter((r): r is typeof r & { organizationId: string } => Boolean(r.organizationId))
+    .map((r) => ({ organizationId: r.organizationId, key: r.value }))
+}
+
+async function revalidateAll(orgs: KeyedOrg[]) {
+  for (const org of orgs) {
+    try {
+      await revalidateOrganizationLicense(org.organizationId, org.key)
+    } catch (error) {
+      console.error(`[cron] Failed to revalidate license for org ${org.organizationId}:`, error)
+    }
+  }
+}
+
+/**
+ * Runs once at boot. Any org holding a key without a currently valid token
+ * gets refreshed straight away rather than waiting up to a day for the cron.
+ * That is the whole upgrade path for installs licensed before signed tokens
+ * existed: they boot into the new release and fetch their token immediately.
+ */
+export async function refreshLicensesMissingTokens() {
+  try {
+    const orgs = await orgsWithKeys()
+    if (orgs.length === 0) return
+    const tokens = await db.appSetting.findMany({
+      where: { key: 'license.token', organizationId: { in: orgs.map((o) => o.organizationId) } },
+      select: { organizationId: true, value: true },
     })
+    const tokenByOrg = new Map(tokens.map((t) => [t.organizationId, t.value]))
+    const needing = orgs.filter(
+      (o) =>
+        verifyLicenseToken(tokenByOrg.get(o.organizationId), o.organizationId).status !== 'valid'
+    )
+    if (needing.length === 0) return
+    await revalidateAll(needing)
   } catch (error) {
-    console.warn(`[cron] Failed to send license expiry email for org ${organizationId}:`, error)
+    console.error('[license] Boot-time licence refresh failed:', error)
   }
 }
 
@@ -153,26 +192,15 @@ export async function sendExpiryWarning(organizationId: string, daysLeft: number
 export function checkLicenses() {
   const job = new CronJob('0 0 * * *', async () => {
     try {
-      const licenseSettings = await db.appSetting.findMany({
-        where: { key: 'license.key' },
-        select: { organizationId: true, value: true },
-      })
-
-      for (const setting of licenseSettings) {
-        if (!setting.organizationId) continue
-        try {
-          await revalidateOrganizationLicense(setting.organizationId, setting.value)
-        } catch (error) {
-          console.error(
-            `[cron] Failed to revalidate license for org ${setting.organizationId}:`,
-            error
-          )
-        }
-      }
+      await revalidateAll(await orgsWithKeys())
     } catch (error) {
       console.error('[cron] License revalidation failed:', error)
     }
   })
 
   job.start()
+
+  // Give the database and the rest of boot a moment before going out to the
+  // network. Not awaited: a slow torqvoice.com must never delay startup.
+  setTimeout(() => void refreshLicensesMissingTokens(), 15_000).unref()
 }

+ 20 - 5
src/lib/features.ts

@@ -1,5 +1,7 @@
 import { cache } from 'react'
 import { db } from './db'
+import { verifyLicenseToken } from './license/token'
+import { scheduleLicenseSelfHeal } from './license/revalidate'
 
 export type Plan = 'free' | 'pro' | 'enterprise' | 'white-label'
 
@@ -186,18 +188,31 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
     return cloudPlan(planName)
   }
 
-  // Self-hosted mode — all features unlocked, license only controls branding
+  // Self-hosted mode — all features unlocked, license only controls branding.
+  //
+  // The gate trusts one thing: a token signed by torqvoice.com, bound to this
+  // organization, refreshed within the last two weeks. The operator owns this
+  // database, so `license.valid` and friends are display cache only; editing
+  // them changes nothing here. See src/lib/license/token.ts.
   const settings = await db.appSetting.findMany({
     where: {
       organizationId,
-      key: { in: ['license.valid', 'license.expiresAt'] },
+      key: { in: ['license.token', 'license.key'] },
     },
   })
 
   const map = new Map(settings.map((s) => [s.key, s.value]))
-  const isValid = map.get('license.valid') === 'true'
-  const expiresAt = map.get('license.expiresAt')
-  const hasLicense = isValid && (!expiresAt || new Date(expiresAt) > new Date())
+  const verification = verifyLicenseToken(map.get('license.token'), organizationId)
+  const hasLicense = verification.status === 'valid'
+
+  // A key with no usable token is an install that has not talked to
+  // torqvoice.com recently, or one that upgraded from the release that stored
+  // plain booleans. Refresh in the background; the cron would get there within
+  // a day anyway, this just makes the upgrade invisible.
+  const key = map.get('license.key')
+  if (key && !hasLicense) {
+    scheduleLicenseSelfHeal(organizationId, key)
+  }
 
   return {
     ...PLAN_FEATURES['white-label'],

+ 14 - 0
src/lib/license/public-keys.ts

@@ -0,0 +1,14 @@
+/**
+ * Public halves of the Ed25519 keys torqvoice.com signs licence tokens with.
+ * Single-line base64 SPKI DER. The private halves never leave torqvoice.com.
+ *
+ * More than one entry is only ever for rotation: add the new key here and
+ * ship it, then switch the signer, then drop the old key a release later.
+ *
+ * This is deliberately a constant and not an environment variable. An env
+ * override would let an operator point the app at a key they hold, which is
+ * the exact bypass the signature exists to close.
+ */
+export const LICENSE_PUBLIC_KEYS: readonly string[] = [
+  'MCowBQYDK2VwAyEA9hxqMSyYdCUUmqab9WuDBzTSiO1raX97rr6M21Xk/RE=',
+]

+ 151 - 0
src/lib/license/revalidate.ts

@@ -0,0 +1,151 @@
+import type { Prisma } from '@/generated/prisma/client'
+import { db } from '@/lib/db'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { verifyLicenseToken, type LicenseTokenVerification } from './token'
+
+/**
+ * Talks to torqvoice.com and stores what comes back.
+ *
+ * Shared by the manual Validate button, the daily cron and the self-heal in
+ * `getFeatures`. Only the signed token matters for entitlements; the plain
+ * rows (`license.valid`, `license.plan`, `license.expiresAt`) are kept as a
+ * display cache for the licence page and mean nothing to the feature gate.
+ */
+
+const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
+
+export type RemoteLicenseResult = {
+  reachable: boolean
+  valid: boolean
+  plan: string
+  expiresAt: string
+  token: string | null
+  error?: string
+}
+
+export async function fetchRemoteLicense(
+  licenseKey: string,
+  organizationId: string
+): Promise<RemoteLicenseResult> {
+  try {
+    const response = await fetch(`${TORQVOICE_COM_URL}/api/license/validate`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ key: licenseKey, organizationId }),
+      signal: AbortSignal.timeout(10000),
+    })
+    if (!response.ok) {
+      return { reachable: false, valid: false, plan: 'free', expiresAt: '', token: null }
+    }
+    const data = await response.json()
+    const valid = data.valid === true
+    return {
+      reachable: true,
+      valid,
+      plan: valid && typeof data.plan === 'string' ? data.plan : 'free',
+      expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : '',
+      token: valid && typeof data.token === 'string' ? data.token : null,
+      error: typeof data.error === 'string' ? data.error : undefined,
+    }
+  } catch {
+    return { reachable: false, valid: false, plan: 'free', expiresAt: '', token: null }
+  }
+}
+
+async function settingUserId(organizationId: string): Promise<string | null> {
+  const member = await db.organizationMember.findFirst({
+    where: { organizationId },
+    select: { userId: true },
+  })
+  return member?.userId ?? null
+}
+
+function upsert(organizationId: string, userId: string, key: string, value: string) {
+  return db.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key } },
+    update: { value },
+    create: { userId, organizationId, key, value },
+  })
+}
+
+/**
+ * Stores a validate response. A reachable answer is authoritative, including
+ * "not valid": the token is dropped. An unreachable torqvoice.com changes
+ * nothing except `license.checkedAt`, so the existing token keeps working
+ * until it ages out on its own.
+ */
+export async function storeRemoteLicense(
+  organizationId: string,
+  licenseKey: string,
+  result: RemoteLicenseResult,
+  userId?: string
+): Promise<void> {
+  const uid = userId ?? (await settingUserId(organizationId))
+  if (!uid) return
+
+  const now = new Date().toISOString()
+  const writes: Prisma.PrismaPromise<unknown>[] = [
+    upsert(organizationId, uid, SETTING_KEYS.LICENSE_KEY, licenseKey),
+    upsert(organizationId, uid, SETTING_KEYS.LICENSE_CHECKED_AT, now),
+  ]
+
+  if (result.reachable) {
+    writes.push(
+      upsert(organizationId, uid, SETTING_KEYS.LICENSE_VALID, String(result.valid)),
+      upsert(organizationId, uid, SETTING_KEYS.LICENSE_PLAN, result.plan)
+    )
+    if (result.expiresAt) {
+      writes.push(upsert(organizationId, uid, SETTING_KEYS.LICENSE_EXPIRES_AT, result.expiresAt))
+    }
+    if (result.token) {
+      writes.push(upsert(organizationId, uid, SETTING_KEYS.LICENSE_TOKEN, result.token))
+    } else {
+      writes.push(
+        db.appSetting.deleteMany({
+          where: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN },
+        })
+      )
+    }
+  }
+
+  await db.$transaction(writes)
+}
+
+/** Fetches and stores in one go. Returns the verification of what is now on disk. */
+export async function revalidateLicense(
+  organizationId: string,
+  licenseKey: string,
+  userId?: string
+): Promise<{ remote: RemoteLicenseResult; verification: LicenseTokenVerification }> {
+  const remote = await fetchRemoteLicense(licenseKey, organizationId)
+  await storeRemoteLicense(organizationId, licenseKey, remote, userId)
+  const stored = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.LICENSE_TOKEN } },
+    select: { value: true },
+  })
+  return { remote, verification: verifyLicenseToken(stored?.value, organizationId) }
+}
+
+// One attempt per org per hour from the request path. The cron does the real
+// work daily; this only catches installs that have a key and no usable token,
+// which is every existing customer on the release that introduced tokens.
+const SELF_HEAL_INTERVAL_MS = 60 * 60 * 1000
+const lastSelfHeal = new Map<string, number>()
+
+/**
+ * Fire-and-forget refresh for an org whose stored token is missing or stale
+ * but which has a licence key. Never awaited by the caller and never throws.
+ */
+export function scheduleLicenseSelfHeal(organizationId: string, licenseKey: string): void {
+  const last = lastSelfHeal.get(organizationId) ?? 0
+  if (Date.now() - last < SELF_HEAL_INTERVAL_MS) return
+  lastSelfHeal.set(organizationId, Date.now())
+  void revalidateLicense(organizationId, licenseKey).catch((error) => {
+    console.warn(`[license] Background refresh failed for org ${organizationId}:`, error)
+  })
+}
+
+/** Test hook. */
+export function resetLicenseSelfHealThrottle(): void {
+  lastSelfHeal.clear()
+}

+ 153 - 0
src/lib/license/token.ts

@@ -0,0 +1,153 @@
+import { createPublicKey, verify, type KeyObject } from 'node:crypto'
+import { LICENSE_PUBLIC_KEYS } from './public-keys'
+
+/**
+ * Verifies the signed licence token torqvoice.com hands out.
+ *
+ * Nothing in the local database is trusted on its own. The token carries the
+ * organization it was minted for, the plan, the expiry and the time it was
+ * signed, and only a valid signature over all of that unlocks branding
+ * removal. See torqvoice.com `src/lib/license-signing.ts` for the signer.
+ *
+ * Two clocks apply:
+ * - `expiresAt` is the licence term itself.
+ * - `issuedAt` must be recent. torqvoice.com re-signs on every daily check,
+ *   so a token older than LICENSE_TOKEN_MAX_AGE_DAYS means the app has not
+ *   been able to reach torqvoice.com for that long, or somebody has cut it
+ *   off deliberately. Either way branding comes back until a fresh token
+ *   arrives. The header warns from LICENSE_TOKEN_WARN_AGE_DAYS so a genuine
+ *   outage never surprises a paying customer.
+ */
+
+export const LICENSE_TOKEN_PREFIX = 'tvl1'
+export const LICENSE_TOKEN_MAX_AGE_DAYS = 14
+export const LICENSE_TOKEN_WARN_AGE_DAYS = 7
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+export type LicenseTokenPayload = {
+  v: 1
+  lid: string
+  org: string
+  plan: string
+  expiresAt: string
+  issuedAt: string
+}
+
+export type LicenseTokenStatus =
+  /** no token stored */
+  | 'missing'
+  /** malformed, bad signature, or minted for another organization */
+  | 'invalid'
+  /** signature fine, licence term is over */
+  | 'expired'
+  /** signature fine, but not refreshed within LICENSE_TOKEN_MAX_AGE_DAYS */
+  | 'stale'
+  | 'valid'
+
+export type LicenseTokenVerification = {
+  status: LicenseTokenStatus
+  /** present whenever the signature checked out, whatever the status */
+  payload: LicenseTokenPayload | null
+  /** whole days since the token was signed; null without a payload */
+  ageDays: number | null
+  /** whole days until the licence term ends; null without a payload */
+  daysUntilExpiry: number | null
+}
+
+const NOT_VERIFIED: LicenseTokenVerification = {
+  status: 'invalid',
+  payload: null,
+  ageDays: null,
+  daysUntilExpiry: null,
+}
+
+let cachedKeys: KeyObject[] | null = null
+
+function embeddedKeys(): KeyObject[] {
+  if (!cachedKeys) {
+    cachedKeys = LICENSE_PUBLIC_KEYS.map((k) =>
+      createPublicKey({ key: Buffer.from(k, 'base64'), format: 'der', type: 'spki' })
+    )
+  }
+  return cachedKeys
+}
+
+function parsePayload(encoded: string): LicenseTokenPayload | null {
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'))
+  } catch {
+    return null
+  }
+  if (!parsed || typeof parsed !== 'object') return null
+  const p = parsed as Record<string, unknown>
+  if (p.v !== 1) return null
+  for (const field of ['lid', 'org', 'plan', 'expiresAt', 'issuedAt']) {
+    if (typeof p[field] !== 'string' || !(p[field] as string)) return null
+  }
+  if (Number.isNaN(Date.parse(p.expiresAt as string))) return null
+  if (Number.isNaN(Date.parse(p.issuedAt as string))) return null
+  return p as LicenseTokenPayload
+}
+
+/**
+ * Verifies with an explicit key set. Tests use this with a throwaway pair;
+ * production code goes through `verifyLicenseToken`, which pins the embedded
+ * keys.
+ */
+export function verifyLicenseTokenWith(
+  keys: readonly KeyObject[],
+  token: string | null | undefined,
+  organizationId: string,
+  now: Date = new Date()
+): LicenseTokenVerification {
+  if (!token) return { ...NOT_VERIFIED, status: 'missing' }
+
+  const parts = token.trim().split('.')
+  if (parts.length !== 3 || parts[0] !== LICENSE_TOKEN_PREFIX) return NOT_VERIFIED
+  const [, encoded, sig] = parts
+
+  let signature: Buffer
+  try {
+    signature = Buffer.from(sig, 'base64url')
+  } catch {
+    return NOT_VERIFIED
+  }
+  if (signature.length !== 64) return NOT_VERIFIED
+
+  const data = Buffer.from(encoded, 'utf8')
+  const signed = keys.some((key) => {
+    try {
+      return verify(null, data, key, signature)
+    } catch {
+      return false
+    }
+  })
+  if (!signed) return NOT_VERIFIED
+
+  const payload = parsePayload(encoded)
+  if (!payload) return NOT_VERIFIED
+  // A token is bound to the org that asked for it. One lifted from another
+  // install, or minted for a different org on the same install, is rejected.
+  if (payload.org !== organizationId) return NOT_VERIFIED
+
+  const ageMs = now.getTime() - Date.parse(payload.issuedAt)
+  const ageDays = Math.floor(ageMs / DAY_MS)
+  const daysUntilExpiry = Math.ceil((Date.parse(payload.expiresAt) - now.getTime()) / DAY_MS)
+  const base = { payload, ageDays, daysUntilExpiry }
+
+  if (daysUntilExpiry <= 0) return { ...base, status: 'expired' }
+  // A future issuedAt is a clock problem, not a forgery (the signature held),
+  // so it is treated as fresh rather than rejected.
+  if (ageMs > LICENSE_TOKEN_MAX_AGE_DAYS * DAY_MS) return { ...base, status: 'stale' }
+  return { ...base, status: 'valid' }
+}
+
+export function verifyLicenseToken(
+  token: string | null | undefined,
+  organizationId: string,
+  now: Date = new Date()
+): LicenseTokenVerification {
+  return verifyLicenseTokenWith(embeddedKeys(), token, organizationId, now)
+}