grammargem/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift
BigMarc 201fe04d8a feat: backend-neutral AI seam, Ollama backend, extension bridge + trust/correctness fixes
Wave 1 (correctness/trust):
- Fix paid large-model dead path: engine reads the persisted selected repo via a
  thread-safe ModelManager.activeRepo(), so download/load/isReady now agree.
- Lemon Squeezy store/product check fails closed in release builds — the
  placeholder-ID 'any key unlocks paid' bypass can no longer ship.
- Replace force-unwrapped Hugging Face URLs with safe URLComponents construction.
- Never read/replace/scan secure (password) fields via AX.isSecureTextField.

Wave 2 (compounding seam):
- Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so
  every backend produces identical prompts and output.
- AIEngine gains protectedTerms (personal dictionary now actually injected into the
  LLM), warmup(), and runStreaming(); add makeAIEngine() factory.
- Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download.
- Guard custom modes against empty prompts and duplicate names.

Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in
via GRAMMARGEM_AI_BACKEND=ollama).

Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework,
no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a
browser extension.

Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:21:29 +02:00

140 lines
6.2 KiB
Swift

import Foundation
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
/// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier.
final class LemonSqueezyClient {
private let session: URLSession
private let base = AppConfig.LemonSqueezy.apiBase
init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Endpoints
func activate(key: String, instanceName: String) async throws -> ActivationResult {
let res: LSActivateResponse = try await post(
"licenses/activate",
params: ["license_key": key, "instance_name": instanceName])
if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
guard res.activated, let instance = res.instance else {
throw LicenseError.serverMessage("Activation failed.")
}
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return ActivationResult(
instanceID: instance.id, tier: tier,
usage: res.license_key?.activation_usage ?? 1,
limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
func validate(key: String, instanceID: String?) async throws -> ValidationResult {
var params = ["license_key": key]
if let instanceID { params["instance_id"] = instanceID }
let res: LSValidateResponse = try await post("licenses/validate", params: params)
if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return ValidationResult(
valid: res.valid, tier: tier,
usage: res.license_key?.activation_usage ?? 0,
limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
@discardableResult
func deactivate(key: String, instanceID: String) async throws -> Bool {
let res: LSDeactivateResponse = try await post(
"licenses/deactivate",
params: ["license_key": key, "instance_id": instanceID])
if let err = res.error { throw mapServerError(err) }
return res.deactivated
}
// MARK: - Hard verification (spec §6)
/// Confirm the key is for OUR store/product, then resolve which tier its
/// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem.
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
guard let meta else { throw LicenseError.wrongProduct }
// Store + product check. With real numeric IDs configured this is a hard
// gate that prevents a key from another Lemon Squeezy product unlocking
// GrammarGem. If AppConfig still holds placeholder (non-numeric) IDs the
// gate can't run — allow the bypass ONLY in DEBUG so local stub flows work;
// in a release build FAIL CLOSED so the "any key unlocks paid" bypass can
// never ship. TODO(real-integration): set real IDs in AppConfig.LemonSqueezy.
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
throw LicenseError.wrongProduct
}
} else {
#if DEBUG
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig (DEBUG only).")
#else
Log.licensing.error("Refusing to verify license: placeholder Lemon Squeezy IDs in a release build.")
throw LicenseError.wrongProduct
#endif
}
// Resolve tier from the variant id (reverse-lookup the configured map).
if let variantID = meta.variant_id {
for (tier, idString) in AppConfig.LemonSqueezy.variantID {
if Int(idString) == variantID { return tier }
}
}
// Fall back to whatever the variant name hints at, else Solo.
switch meta.variant_name?.lowercased() {
case let v? where v.contains("studio"): return .studio
case let v? where v.contains("personal"): return .personal
default: return .solo
}
}
private func mapServerError(_ message: String, limit: Int? = nil) -> LicenseError {
let m = message.lowercased()
if m.contains("activation limit") {
return .activationLimitReached(limit ?? 0)
}
if m.contains("not found") || m.contains("invalid") {
return .invalidKey
}
return .serverMessage(message)
}
// MARK: - Transport
private func post<T: Decodable>(_ path: String, params: [String: String]) async throws -> T {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = formEncode(params).data(using: .utf8)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicenseError.network("No HTTP response")
}
// LS returns 200 + {activated/valid:false} for soft failures, and 4xx
// with a JSON `error`. Try to decode the body either way.
if let decoded = try? JSONDecoder().decode(T.self, from: data) {
return decoded
}
throw LicenseError.network("Unexpected response (\(http.statusCode))")
} catch let e as LicenseError {
throw e
} catch {
throw LicenseError.network(error.localizedDescription)
}
}
private func formEncode(_ params: [String: String]) -> String {
params.map { key, value in
let v = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return "\(key)=\(v)"
}.joined(separator: "&")
}
}