[sub-spm-optional-products] Add LCECryptoKit and LCFeatureControl as optional sub-SPM products

LCEssentials installs standalone; each sub target-depends on it so linking a
sub's product always pulls LCEssentials in too, without the consumer having to
declare it separately.

- LCECryptoKit: internalized from the remote LCECryptoKitBinary git dependency
  (embedded token URL removed) into a local binaryTarget vendoring
  Frameworks/LCECryptoKit.xcframework. LCECryptoKitManager moved out of
  LCEssentials core into its own LCECryptoKitManager target/product; the
  no-op fallback for when the binary wasn't linked is gone (breaking change
  for existing consumers, see decisions/2026-09-15-sub-spm-optional-products.md).
- LCFeatureControl: new product wrapping Atomenta's Feature Control API
  (flag evaluation with TTL cache + safe-degrade fallback to defaults,
  notifications inbox, batched exposure telemetry). 45 new tests.
This commit is contained in:
Daniel Arantes Loverde
2026-09-16 09:23:57 -03:00
parent d067791930
commit 2ba487a6e8
47 changed files with 4264 additions and 113 deletions

View File

@@ -0,0 +1,51 @@
import Foundation
import LCEssentials
/// Explicit failure for callers that want to see it (`evaluateOrThrow`, the
/// notifications client). The safe `evaluate(...)` path never surfaces this
/// it degrades to cache/defaults instead (FC-060 §4).
public enum FeatureControlError: Error, Sendable, Equatable {
/// 400 `FEATURE_CONTROL_CONTEXT_INVALID`
case invalidContext(code: String)
/// 401 `MISSING_AUTH` / `INVALID_MODULE_TOKEN`
case unauthorized(code: String)
/// 403 `PANEL_ROLE_REQUIRED` / `INSUFFICIENT_PERMISSIONS`
case forbidden(code: String)
/// 429 `FEATURE_CONTROL_RATE_LIMIT`
case rateLimited(code: String)
/// Any other non-2xx status.
case server(code: String, status: Int)
/// Decoding failure, or any error not raised by `LCEssentials.API`'s HTTP path.
case transport(message: String)
}
enum FeatureControlErrorMapper {
/// `LCEssentials.API` throws an `NSError` (domain `LCEssentials.DEFAULT_ERROR_DOMAIN`,
/// `code` = HTTP status, `localizedFailureReason` = pretty-printed body) for non-2xx
/// responses, or a bridged `DecodingError`/`URLError` for anything else. Only the
/// former carries a real HTTP status to map.
static func map(_ error: Error) -> FeatureControlError {
let nsError = error as NSError
guard nsError.domain == LCEssentials.DEFAULT_ERROR_DOMAIN else {
return .transport(message: nsError.localizedDescription)
}
let status = nsError.code
let bodyCode = extractCode(from: nsError.localizedFailureReason) ?? "UNKNOWN"
switch status {
case 400: return .invalidContext(code: bodyCode)
case 401: return .unauthorized(code: bodyCode)
case 403: return .forbidden(code: bodyCode)
case 429: return .rateLimited(code: bodyCode)
default: return .server(code: bodyCode, status: status)
}
}
private static func extractCode(from prettyJSON: String?) -> String? {
guard let prettyJSON,
let data = prettyJSON.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = object["code"] as? String else { return nil }
return code
}
}