Skip to content

Commit 0abc704

Browse files
committed
Licensing: UI, verify/commit split, typed errors
- Split activation into verify (read-only) → server validate → commit (persist), so invalid keys never touch disk - Replace `Result<_, String>` with `LicenseActivationError` typed enum across Rust + TypeScript (frontend switches on `code`, not substrings) - Distinguish `Success`/`UpstreamError`/`NetworkError` in validation so Paddle outages don't overwrite cached "active" status - License server returns 502 on Paddle failures and strips multi-seat transaction ID suffixes - `LicenseKeyDialog` shows key-value details view with pending verification state, server-invalid warnings, reset flow, and success toasts - Embed `shortCode` in signed license payload, add `success` toast level, allow `reset_license` in release builds - Paddle.js loaded async with proper init sequencing on pricing page - Updated CLAUDE.md files for licensing (Rust + Svelte), UI, and license server - Add new toast notification type ("success")
1 parent 2af0b90 commit 0abc704

26 files changed

Lines changed: 1394 additions & 270 deletions

apps/desktop/src-tauri/src/commands/licensing.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,36 @@ pub fn get_window_title(app: tauri::AppHandle) -> String {
1515
licensing::get_window_title(&status)
1616
}
1717

18-
/// Activate a license key or short code.
18+
/// Activate a license key or short code (verify + commit in one call).
1919
/// If the input is a short code (CMDR-XXXX-XXXX-XXXX), it first exchanges it for the full key.
20+
/// Kept for backward compatibility — new code should use verify_license + commit_license.
2021
#[tauri::command]
21-
pub async fn activate_license(app: tauri::AppHandle, license_key: String) -> Result<licensing::LicenseInfo, String> {
22+
pub async fn activate_license(
23+
app: tauri::AppHandle,
24+
license_key: String,
25+
) -> Result<licensing::LicenseInfo, licensing::LicenseActivationError> {
2226
licensing::activate_license_async(&app, &license_key).await
2327
}
2428

29+
/// Verify a license key or short code without writing anything to disk.
30+
/// Returns the verify result (LicenseInfo + full key) for the frontend to inspect
31+
/// before deciding whether to commit.
32+
#[tauri::command]
33+
pub async fn verify_license(license_key: String) -> Result<licensing::VerifyResult, licensing::LicenseActivationError> {
34+
licensing::verify_license_async(&license_key).await
35+
}
36+
37+
/// Persist a verified license key to disk and update caches.
38+
/// Only call after verification confirms the key is valid.
39+
#[tauri::command]
40+
pub fn commit_license(
41+
app: tauri::AppHandle,
42+
license_key: String,
43+
short_code: Option<String>,
44+
) -> Result<licensing::LicenseInfo, licensing::LicenseActivationError> {
45+
licensing::commit_license(&app, &license_key, short_code.as_deref())
46+
}
47+
2548
/// Get information about the current license (if any).
2649
#[tauri::command]
2750
pub fn get_license_info(app: tauri::AppHandle) -> Option<licensing::LicenseInfo> {
@@ -52,8 +75,20 @@ pub fn needs_license_validation(app: tauri::AppHandle) -> bool {
5275
licensing::needs_validation(&app)
5376
}
5477

78+
/// Check if a server validation has ever completed for the current license.
79+
#[tauri::command]
80+
pub fn has_license_been_validated(app: tauri::AppHandle) -> bool {
81+
licensing::has_been_validated(&app)
82+
}
83+
5584
/// Validate license with server (async - call when needs_license_validation returns true).
85+
/// If `transaction_id` is provided, uses it directly (for pre-commit validation).
86+
/// If `None`, reads from the stored license (for periodic re-validation).
87+
/// Returns Err on network/upstream errors so the frontend can distinguish from server rejection.
5688
#[tauri::command]
57-
pub async fn validate_license_with_server(app: tauri::AppHandle) -> licensing::AppStatus {
58-
licensing::validate_license_async(&app).await
89+
pub async fn validate_license_with_server(
90+
app: tauri::AppHandle,
91+
transaction_id: Option<String>,
92+
) -> Result<licensing::AppStatus, String> {
93+
licensing::validate_license_async(&app, transaction_id.as_deref()).await
5994
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,11 +736,14 @@ pub fn run() {
736736
commands::licensing::get_license_status,
737737
commands::licensing::get_window_title,
738738
commands::licensing::activate_license,
739+
commands::licensing::verify_license,
740+
commands::licensing::commit_license,
739741
commands::licensing::get_license_info,
740742
commands::licensing::mark_expiration_modal_shown,
741743
commands::licensing::mark_commercial_reminder_dismissed,
742744
commands::licensing::reset_license,
743745
commands::licensing::needs_license_validation,
746+
commands::licensing::has_license_been_validated,
744747
commands::licensing::validate_license_with_server,
745748
// AI commands
746749
ai::manager::get_ai_status,

apps/desktop/src-tauri/src/licensing/CLAUDE.md

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ License keys are self-contained: `base64(JSON payload).base64(Ed25519 signature)
99
| File | Purpose |
1010
|---|---|
1111
| `mod.rs` | `LicenseData` struct, `redact_email` helper, re-exports from sub-modules |
12-
| `verification.rs` | Ed25519 crypto. Validates key format, verifies signature, caches result in `Mutex`. `activate_license` (sync), `activate_license_async` (handles short-code exchange), `get_license_info` (lazy, cached). |
12+
| `verification.rs` | Ed25519 crypto. `LicenseActivationError` typed error enum. Validates key format, verifies signature, caches result in `Mutex`. Split into verify/commit: `verify_license_async` (read-only check + short-code exchange), `commit_license` (persist to disk + update caches). Legacy wrappers: `activate_license` (sync verify+commit), `activate_license_async` (async verify+commit). `get_license_info` (lazy, cached). `VerifyResult` struct wraps `LicenseInfo` + `full_key` + `short_code`. |
1313
| `app_status.rs` | `AppStatus` enum. 7-day server re-validation, 30-day offline grace period. Commercial use reminder timer. Debug: `CMDR_MOCK_LICENSE` env var overrides everything. |
14-
| `validation_client.rs` | HTTP client: `POST /validate`, `POST /activate`. Debug → `localhost:8787`, release → `license.getcmdr.com`. Mock mode skips network entirely. |
14+
| `validation_client.rs` | HTTP client: `POST /validate`, `POST /activate`. Debug → `localhost:8787`, release → `license.getcmdr.com`. Mock mode skips network entirely. Returns `ValidationOutcome` enum (Success/UpstreamError/NetworkError). |
1515

1616
## AppStatus variants
1717

@@ -31,20 +31,38 @@ Expired { organization_name, expired_at, show_modal }
3131

3232
Offline grace period: 30 days. After that, status reverts to Personal until next successful server validation.
3333

34-
## Activation flow
34+
## Activation flow (verify/commit split)
35+
36+
The activation flow is split into two phases to prevent invalid keys from being persisted to disk.
3537

3638
```
37-
User enters key or short code
39+
Frontend: verifyLicense(input)
3840
|
3941
|-- is_short_code("CMDR-XXXX-XXXX-XXXX")?
4042
| YES → POST /activate → get full crypto key
4143
|
4244
v
4345
validate_license_key() ← Ed25519 verify offline
44-
store to license.json ← persisted
45-
update LICENSE_CACHE ← in-memory fast path
46+
return VerifyResult ← LicenseInfo + full_key + short_code (nothing stored)
47+
48+
Frontend: validateLicenseWithServer(transactionId)
49+
| ↑ passed explicitly since key isn't stored yet
50+
v
51+
Server says active/supporter → commitLicense(fullKey, shortCode) → persist + onSuccess
52+
Server says expired → commitLicense(fullKey, shortCode) → persist + show error
53+
Server says invalid → DON'T commit. Show error. Nothing stored.
54+
Network error → commitLicense(fullKey, shortCode) → persist + fallback
4655
```
4756

57+
`commit_license` does: store to `license.json`, write initial `cached_license_status`, update `LICENSE_CACHE`.
58+
59+
`VerifyResult` fields: `info` (LicenseInfo), `full_key`, `short_code`.
60+
`LicenseInfo` fields: `email`, `transaction_id`, `issued_at`, `organization_name`, `license_type`, `short_code`.
61+
The frontend uses `license_type` to construct a fallback `LicenseStatus` when the server is unavailable.
62+
63+
Legacy `activate_license`/`activate_license_async` wrappers still exist for backward compatibility — they call
64+
`commit_license` internally (verify + commit in one call).
65+
4866
## Key patterns
4967

5068
- Short codes: `CMDR-XXXX-XXXX-XXXX` (3 segments × 4 alphanumeric chars after CMDR prefix).
@@ -71,6 +89,18 @@ update LICENSE_CACHE ← in-memory fast path
7189
**Decision**: Short codes (`CMDR-XXXX-XXXX-XXXX`) exchanged server-side for full crypto keys, rather than being directly verifiable.
7290
**Why**: Short codes are human-friendly for typing and sharing, but too short to embed a full Ed25519 signature. The server maps short codes to full keys, so users get a nice entry experience while the app still gets a cryptographically verifiable key for offline use.
7391

92+
**Decision**: `LicenseActivationError` typed enum instead of `Result<_, String>` for activation errors.
93+
**Why**: The frontend was pattern-matching English substrings to decide which error message to show. A tagged enum (`#[serde(tag = "code")]`) serializes as `{ code: "badSignature" }` so the frontend can `switch` on the code. Follows the same pattern as `MtpConnectionError`. The enum lives in `verification.rs` and is used by `validation_client.rs` too.
94+
95+
**Decision**: Verify/commit split — `verify_license_async` (read-only) + `commit_license` (persist), instead of a single `activate_license_internal` that does both.
96+
**Why**: The old flow stored the key before server validation. If the server said "invalid" and the user force-quit (or the app crashed), the invalid key persisted to disk. Now the frontend verifies first, validates with the server, and only commits on success or network fallback. Invalid keys never touch disk, eliminating the need for defensive `resetLicense()` cleanup in error handlers.
97+
98+
**Decision**: `VerifyResult` is a separate struct from `LicenseInfo`.
99+
**Why**: `VerifyResult` carries `full_key` (needed to call `commit_license` later) and `short_code`. These fields shouldn't leak to the frontend via `get_license_info` — they're only meaningful during the activation flow. Keeping them separate means `LicenseInfo` stays clean for its primary use case (displaying license details).
100+
101+
**Decision**: `validate_license_async` accepts an optional `transaction_id` parameter.
102+
**Why**: During activation, the key isn't stored yet, so the function can't read the transaction ID from the store. The frontend passes it explicitly. For periodic re-validation (7-day cycle), the parameter is `None` and the function falls back to reading from the stored license. This avoids storing the key just to read the transaction ID back.
103+
74104
**Decision**: `CMDR_MOCK_LICENSE` env var bypasses all license logic including server calls.
75105
**Why**: License UX testing requires seeing every state (personal, supporter, commercial, expired, with/without modals). Without mocking, you'd need real license keys for each variant and a running license server. The mock skips network entirely, making UI development fast.
76106

@@ -82,6 +112,12 @@ update LICENSE_CACHE ← in-memory fast path
82112
**Gotcha**: `ValidationResponse` uses manual `#[serde(rename)]` on individual fields instead of `#[serde(rename_all)]` on the struct.
83113
**Why**: The license server API returns a mix of naming conventions — `status` is lowercase, but `organizationName` and `expiresAt` are camelCase, and `type` is a Rust keyword requiring rename. The struct matches the API as-is rather than imposing a consistent naming convention that would break deserialization.
84114

115+
**Gotcha**: `validate_with_server` returns `ValidationOutcome` (an enum with `Success`/`UpstreamError`/`NetworkError`), not `Option<ValidationResponse>`.
116+
**Why**: The license server returns HTTP 502 when it can't reach Paddle (upstream error) vs HTTP 200 with `status: "invalid"` when Paddle actively says the transaction is unknown. The old code collapsed both into `None`, causing `validate_license_async` to trust a stale "invalid" response from a transient Paddle outage and overwrite the cached "active" status. Now `UpstreamError` and `NetworkError` both fall back to cached status without overwriting, while `Success` (even with `status: "invalid"`) is treated as definitive and cached.
117+
118+
**Gotcha**: `validate_license_async` returns `Result<AppStatus, String>`, not bare `AppStatus`.
119+
**Why**: The Tauri command must propagate network/upstream errors to the frontend so it can distinguish "server actively rejected the key" (`Ok(Personal)`) from "couldn't reach the server" (`Err`). Without this, the frontend's catch block never fires — Tauri's `invoke` only throws on `Err` — and stale cached `Personal` status gets misinterpreted as a server rejection.
120+
85121
## Dependencies
86122

87123
External: `ed25519-dalek`, `base64`, `reqwest`, `tauri_plugin_store`

apps/desktop/src-tauri/src/licensing/app_status.rs

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -128,34 +128,51 @@ pub fn needs_validation(app: &tauri::AppHandle) -> bool {
128128
}
129129
}
130130

131+
/// Check if a server validation has ever completed successfully.
132+
/// Returns false if no `last_validation_timestamp` exists (license was committed locally but never server-verified).
133+
pub fn has_been_validated(app: &tauri::AppHandle) -> bool {
134+
let store = match app.store("license.json") {
135+
Ok(s) => s,
136+
Err(_) => return false,
137+
};
138+
store.get(STORE_KEY_LAST_VALIDATION).and_then(|v| v.as_u64()).is_some()
139+
}
140+
131141
/// Validate license with server asynchronously.
142+
/// If `transaction_id` is provided, uses it directly (for pre-commit validation).
143+
/// If `None`, reads from the stored license (for periodic 7-day re-validation).
132144
/// Returns the updated AppStatus after validation.
133-
pub async fn validate_license_async(app: &tauri::AppHandle) -> AppStatus {
145+
pub async fn validate_license_async(app: &tauri::AppHandle, transaction_id: Option<&str>) -> Result<AppStatus, String> {
146+
use crate::licensing::validation_client::ValidationOutcome;
147+
134148
// In debug builds, check for mock mode first
135149
#[cfg(debug_assertions)]
136150
if let Some(status) = get_mock_status(app) {
137-
return status;
151+
return Ok(status);
138152
}
139153

140-
// Check for a valid license key
141-
let license_info = match get_license_info(app) {
142-
Some(info) => info,
143-
None => {
144-
return AppStatus::Personal {
145-
show_commercial_reminder: should_show_commercial_reminder(app),
146-
};
147-
}
154+
// Resolve the transaction ID: use explicit parameter or fall back to stored license
155+
let resolved_transaction_id = match transaction_id {
156+
Some(id) => id.to_string(),
157+
None => match get_license_info(app) {
158+
Some(info) => info.transaction_id,
159+
None => {
160+
return Ok(AppStatus::Personal {
161+
show_commercial_reminder: should_show_commercial_reminder(app),
162+
});
163+
}
164+
},
148165
};
149166

150167
// Call the license server
151-
let response = crate::licensing::validation_client::validate_with_server(&license_info.transaction_id).await;
168+
let outcome = crate::licensing::validation_client::validate_with_server(&resolved_transaction_id).await;
152169

153-
match response {
154-
Some(resp) => {
170+
match outcome {
171+
ValidationOutcome::Success(resp) => {
155172
// Convert response to LicenseType
156173
let license_type = resp.license_type.as_deref().and_then(string_to_license_type);
157174

158-
// Update cache
175+
// Update cache — server gave a definitive answer
159176
update_cached_status(
160177
app,
161178
&resp.status,
@@ -165,12 +182,15 @@ pub async fn validate_license_async(app: &tauri::AppHandle) -> AppStatus {
165182
);
166183

167184
// Return the new status based on the response
168-
response_to_app_status(app, &resp)
185+
Ok(response_to_app_status(app, &resp))
169186
}
170-
None => {
171-
// Network error - fall back to cached status
172-
log::warn!("License validation failed, using cached status");
173-
get_app_status(app)
187+
ValidationOutcome::UpstreamError => {
188+
log::warn!("License server couldn't reach Paddle");
189+
Err("License server couldn't reach payment provider".to_string())
190+
}
191+
ValidationOutcome::NetworkError => {
192+
log::warn!("License validation network error");
193+
Err("Couldn't reach the license server".to_string())
174194
}
175195
}
176196
}
@@ -191,7 +211,7 @@ fn response_to_app_status(
191211
}
192212

193213
/// Convert string to LicenseType.
194-
fn string_to_license_type(s: &str) -> Option<LicenseType> {
214+
pub fn string_to_license_type(s: &str) -> Option<LicenseType> {
195215
match s {
196216
"supporter" => Some(LicenseType::Supporter),
197217
"commercial_subscription" => Some(LicenseType::CommercialSubscription),
@@ -342,6 +362,32 @@ pub fn mark_commercial_reminder_dismissed(app: &tauri::AppHandle) {
342362
}
343363
}
344364

365+
/// Write cached license status without marking it as server-validated.
366+
/// Used by `commit_license` so the app shows the correct license type immediately,
367+
/// while `needs_validation()` still returns true (triggering server verification on next launch).
368+
pub fn write_cached_status_without_validation(
369+
app: &tauri::AppHandle,
370+
status: &str,
371+
license_type: Option<LicenseType>,
372+
organization_name: Option<String>,
373+
expires_at: Option<String>,
374+
) {
375+
if let Ok(store) = app.store("license.json") {
376+
let cached = CachedLicenseStatus {
377+
status: status.to_string(),
378+
license_type,
379+
organization_name,
380+
expires_at,
381+
cached_at: current_timestamp(),
382+
};
383+
store.set(STORE_KEY_CACHED_STATUS, serde_json::json!(cached));
384+
385+
if status != "expired" {
386+
store.delete(STORE_KEY_EXPIRATION_SHOWN);
387+
}
388+
}
389+
}
390+
345391
/// Update cached license status from server response.
346392
pub fn update_cached_status(
347393
app: &tauri::AppHandle,
@@ -378,24 +424,19 @@ pub fn get_window_title(status: &AppStatus) -> String {
378424
}
379425
}
380426

381-
/// Reset license data (for testing only).
382-
#[cfg(debug_assertions)]
427+
/// Reset license data, returning the app to unlicensed (Personal) state.
383428
pub fn reset_license(app: &tauri::AppHandle) {
384429
crate::licensing::verification::clear_license_cache();
385430
if let Ok(store) = app.store("license.json") {
386431
store.delete("license_key");
432+
store.delete("license_short_code");
387433
store.delete(STORE_KEY_CACHED_STATUS);
388434
store.delete(STORE_KEY_LAST_VALIDATION);
389435
store.delete(STORE_KEY_EXPIRATION_SHOWN);
390436
store.delete(STORE_KEY_REMINDER_LAST_DISMISSED);
391437
}
392438
}
393439

394-
#[cfg(not(debug_assertions))]
395-
pub fn reset_license(_app: &tauri::AppHandle) {
396-
// No-op in release builds
397-
}
398-
399440
fn current_timestamp() -> u64 {
400441
SystemTime::now()
401442
.duration_since(UNIX_EPOCH)

apps/desktop/src-tauri/src/licensing/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ mod validation_client;
88
mod verification;
99

1010
pub use app_status::{
11-
AppStatus, LicenseType, get_app_status, get_window_title, mark_commercial_reminder_dismissed,
11+
AppStatus, LicenseType, get_app_status, get_window_title, has_been_validated, mark_commercial_reminder_dismissed,
1212
mark_expiration_modal_shown, needs_validation, reset_license, update_cached_status, validate_license_async,
13+
write_cached_status_without_validation,
14+
};
15+
pub use verification::{
16+
LicenseActivationError, LicenseInfo, VerifyResult, activate_license, activate_license_async, commit_license,
17+
get_license_info, verify_license_async,
1318
};
14-
pub use verification::{LicenseInfo, activate_license, activate_license_async, get_license_info};
1519

1620
use serde::{Deserialize, Serialize};
1721

@@ -35,4 +39,6 @@ pub struct LicenseData {
3539
pub license_type: Option<String>,
3640
#[serde(rename = "organizationName")]
3741
pub organization_name: Option<String>,
42+
#[serde(rename = "shortCode")]
43+
pub short_code: Option<String>,
3844
}

0 commit comments

Comments
 (0)