[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,39 @@
import Foundation
/// Pluggable auth for outgoing Feature Control requests. No `Atomenta-Token` type
/// exists here on purpose that module token must never be embedded in a
/// customer-facing app (see FC-060 §1 in Atomenta's `docs/feature-control/`).
/// A consumer who insists on it does so explicitly via `FeatureControlHeaderAuth`.
public protocol FeatureControlAuthorizing: Sendable {
func authorize(_ headers: inout [String: String]) async
}
/// For internal/admin apps hitting Atomenta directly with a panel-role JWT.
public struct FeatureControlBearerAuth: FeatureControlAuthorizing {
private let tokenProvider: @Sendable () async -> String?
public init(tokenProvider: @escaping @Sendable () async -> String?) {
self.tokenProvider = tokenProvider
}
public func authorize(_ headers: inout [String: String]) async {
guard let token = await tokenProvider() else { return }
headers["Authorization"] = "Bearer \(token)"
}
}
/// For a customer app calling its own BFF, which enforces its own auth. Adds
/// exactly the given headers never synthesizes an `Authorization` header.
public struct FeatureControlHeaderAuth: FeatureControlAuthorizing {
private let headers: [String: String]
public init(headers: [String: String]) {
self.headers = headers
}
public func authorize(_ headers: inout [String: String]) async {
for (key, value) in self.headers {
headers[key] = value
}
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// Identifies a cached evaluation FC-060 §5: "environment + subjectId + keys(sorted) +
/// platform + appVersion". `sortedKeys` means caller `keys` order never affects hits.
struct FeatureControlCacheKey: Hashable, Sendable {
let environment: String
let subjectId: String
let sortedKeys: [String]
let platform: String?
let appVersion: String?
}
/// In-memory-only TTL cache (no disk persistence see SDD §2 Non-Goals). The clock is
/// injectable so tests can force expiry deterministically instead of sleeping.
actor FeatureControlCache {
private struct Entry {
let snapshot: FeatureControlSnapshot
let storedAt: Date
let ttl: TimeInterval
}
private var entries: [FeatureControlCacheKey: Entry] = [:]
private let now: @Sendable () -> Date
init(now: @escaping @Sendable () -> Date = { Date() }) {
self.now = now
}
/// `allowStale: true` returns an expired entry rather than `nil` used by the
/// safe-degrade fallback path (SDD §4.4/§4.5).
func get(_ key: FeatureControlCacheKey, allowStale: Bool) -> FeatureControlSnapshot? {
guard let entry = entries[key] else { return nil }
let age = now().timeIntervalSince(entry.storedAt)
if age <= entry.ttl { return entry.snapshot }
return allowStale ? entry.snapshot : nil
}
/// Always overwrites a fresh fetch's `configVersion` simply replaces whatever was there.
func set(_ key: FeatureControlCacheKey, snapshot: FeatureControlSnapshot, ttl: TimeInterval) {
entries[key] = Entry(snapshot: snapshot, storedAt: now(), ttl: ttl)
}
func invalidateAll() {
entries.removeAll()
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// Wiring for one Atomenta Feature Control module deployment.
public struct FeatureControlConfiguration: Sendable {
public var baseURL: String
public var evaluatePath: String
public var notificationsPath: String
public var telemetryPath: String
public var environment: String
public var auth: any FeatureControlAuthorizing
/// In-memory cache TTL. FC-060 §5 recommends 3060s client-side.
public var cacheTTL: TimeInterval
/// FC-060 §5 recommends 1.53s at the BFF; same budget applies here.
public var requestTimeout: TimeInterval
public init(baseURL: String,
environment: String,
auth: any FeatureControlAuthorizing,
evaluatePath: String = "/api/feature-control/evaluate",
notificationsPath: String = "/api/feature-control/notifications",
telemetryPath: String = "/api/feature-control/telemetry/exposure",
cacheTTL: TimeInterval = 45,
requestTimeout: TimeInterval = 3) {
self.baseURL = baseURL
self.environment = environment
self.auth = auth
self.evaluatePath = evaluatePath
self.notificationsPath = notificationsPath
self.telemetryPath = telemetryPath
self.cacheTTL = cacheTTL
self.requestTimeout = requestTimeout
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
/// Who/what a flag evaluation is for mirrors Atomenta's `context.subjectType` enum.
public enum FeatureControlSubjectType: String, Codable, Sendable {
case user, customer, store, anonymous
}
/// Evaluation context sent as `context` in `POST /api/feature-control/evaluate`.
public struct FeatureControlContext: Encodable, Sendable, Equatable {
public var subjectType: FeatureControlSubjectType
public var subjectId: String
public var storeId: String?
public var platform: String?
public var appVersion: String?
public var attributes: [String: String]?
public init(subjectType: FeatureControlSubjectType,
subjectId: String,
storeId: String? = nil,
platform: String? = nil,
appVersion: String? = nil,
attributes: [String: String]? = nil) {
self.subjectType = subjectType
self.subjectId = subjectId
self.storeId = storeId
self.platform = platform
self.appVersion = appVersion
self.attributes = attributes
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
/// Parses the ISO-8601 timestamps Atomenta sends (`2026-04-16T12:00:00.000Z`, with
/// milliseconds). `LCEssentials.API`'s internal `JSONDecoder` uses the default
/// (`.deferredToDate`, numeric epoch) strategy, so `Date` fields on wire models
/// decode the raw string manually instead of relying on `Decodable`'s default
/// date handling.
enum FeatureControlDateParsing {
static func parse(_ string: String) -> Date? {
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFractional.date(from: string) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: string)
}
}

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
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
/// One exposure event for `POST /api/feature-control/telemetry/exposure`.
public struct FeatureControlExposureEvent: Encodable, Sendable, Equatable {
public let featureKey: String
public let variant: String?
public let subjectType: FeatureControlSubjectType
public let storeId: String?
public init(featureKey: String, variant: String?, subjectType: FeatureControlSubjectType, storeId: String?) {
self.featureKey = featureKey
self.variant = variant
self.subjectType = subjectType
self.storeId = storeId
}
}
/// Request body server caps `events` at 100 (`maxItems: 100`); batching into that
/// limit is the caller's (`FeatureControlManager`'s) job, not this type's.
struct FeatureControlExposureBatchBody: Encodable, Sendable {
let events: [FeatureControlExposureEvent]
}
/// Wire envelope for the telemetry response `{error, code, result: {count}}`.
struct FeatureControlExposureBatchEnvelope: Decodable, Sendable {
let error: Bool
let code: String?
}

View File

@@ -0,0 +1,61 @@
import Foundation
/// One evaluated flag, as returned inside `EvaluateResponse.result.flags[key]`.
public struct FeatureControlFlag: Decodable, Sendable, Equatable {
public let enabled: Bool
public let variant: String?
public let payload: FeatureControlJSON?
public let reason: String?
public init(enabled: Bool, variant: String?, payload: FeatureControlJSON?, reason: String?) {
self.enabled = enabled
self.variant = variant
self.payload = payload
self.reason = reason
}
}
/// `EvaluateResponse.result` the batch evaluation result for a set of keys.
public struct FeatureControlSnapshot: Sendable, Equatable {
public let evaluatedAt: Date
public let configVersion: Int
public let flags: [String: FeatureControlFlag]
public init(evaluatedAt: Date, configVersion: Int, flags: [String: FeatureControlFlag]) {
self.evaluatedAt = evaluatedAt
self.configVersion = configVersion
self.flags = flags
}
}
extension FeatureControlSnapshot: Decodable {
private enum CodingKeys: String, CodingKey {
case evaluatedAt, configVersion, flags
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawDate = try container.decode(String.self, forKey: .evaluatedAt)
guard let date = FeatureControlDateParsing.parse(rawDate) else {
throw DecodingError.dataCorruptedError(forKey: .evaluatedAt, in: container,
debugDescription: "Unrecognized date format: \(rawDate)")
}
self.evaluatedAt = date
self.configVersion = try container.decode(Int.self, forKey: .configVersion)
self.flags = try container.decode([String: FeatureControlFlag].self, forKey: .flags)
}
}
/// Wire envelope for `POST /api/feature-control/evaluate` `{error, code, result}`.
struct FeatureControlEvaluateEnvelope: Decodable, Sendable {
let error: Bool
let code: String?
let result: FeatureControlSnapshot
}
/// Request body for `POST /api/feature-control/evaluate`.
struct FeatureControlEvaluateRequestBody: Encodable, Sendable {
let environment: String
let keys: [String]
let context: FeatureControlContext
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// Minimal "any JSON" box for a flag's `payload`, which the backend declares as
/// `additionalProperties: true` (arbitrary shape). No force operations an
/// unrecognized shape throws a `DecodingError`, it never crashes.
public indirect enum FeatureControlJSON: Decodable, Sendable, Equatable {
case null
case bool(Bool)
case number(Double)
case string(String)
case array([FeatureControlJSON])
case object([String: FeatureControlJSON])
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([FeatureControlJSON].self) {
self = .array(value)
} else if let value = try? container.decode([String: FeatureControlJSON].self) {
self = .object(value)
} else {
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Unsupported JSON value for FeatureControlJSON")
}
}
}

View File

@@ -0,0 +1,123 @@
import Foundation
import LCEssentials
/// Protocol seam for DI ViewModels/Interactors depend on this, never on the
/// concrete actor, so a mock can stand in for tests.
public protocol FeatureControlEvaluating: Sendable {
/// Never throws. Fresh fetch stale cache app-supplied `defaults`, in that
/// order (FC-060 §4 "nunca bloquear fluxo crítico"). Use this everywhere a
/// flag gates real product behaviour.
func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot
/// Same fetch, but surfaces the real failure. For admin/debug tooling only.
func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot
/// Drops every cached entry. Call on user/store change, or app foreground if desired
/// this type does not observe app lifecycle itself (SDD §2 Non-Goals).
func invalidateCache() async
}
public actor FeatureControlManager: FeatureControlEvaluating {
private let configuration: FeatureControlConfiguration
private let api: API
private let defaults: [String: FeatureControlFlag]
private let cache: FeatureControlCache
private var exposureBuffer: [FeatureControlExposureEvent] = []
/// `configVersion` sentinel returned when no network response and no cache exist
/// distinguishes "never evaluated" from any real server value (server versions are 0).
public static let unresolvedConfigVersion = -1
public init(configuration: FeatureControlConfiguration,
api: API = .shared,
defaults: [String: FeatureControlFlag] = [:],
now: @escaping @Sendable () -> Date = { Date() }) {
self.configuration = configuration
self.api = api
self.defaults = defaults
self.cache = FeatureControlCache(now: now)
}
public func evaluate(keys: [String], context: FeatureControlContext) async -> FeatureControlSnapshot {
do {
return try await evaluateOrThrow(keys: keys, context: context)
} catch {
let key = cacheKey(keys: keys, context: context)
if let stale = await cache.get(key, allowStale: true) { return stale }
return FeatureControlSnapshot(evaluatedAt: Date(),
configVersion: Self.unresolvedConfigVersion,
flags: defaults)
}
}
public func evaluateOrThrow(keys: [String], context: FeatureControlContext) async throws -> FeatureControlSnapshot {
let key = cacheKey(keys: keys, context: context)
if let fresh = await cache.get(key, allowStale: false) { return fresh }
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
let body = jsonBody(FeatureControlEvaluateRequestBody(
environment: configuration.environment, keys: keys, context: context))
do {
let envelope: FeatureControlEvaluateEnvelope = try await api.request(
url: configuration.baseURL + configuration.evaluatePath,
method: .post,
body: body,
headers: headers,
timeoutInterval: configuration.requestTimeout
)
await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
return envelope.result
} catch {
throw FeatureControlErrorMapper.map(error)
}
}
public func invalidateCache() async {
await cache.invalidateAll()
}
private func cacheKey(keys: [String], context: FeatureControlContext) -> FeatureControlCacheKey {
FeatureControlCacheKey(environment: configuration.environment,
subjectId: context.subjectId,
sortedKeys: keys.sorted(),
platform: context.platform,
appVersion: context.appVersion)
}
}
// MARK: - Exposure telemetry
extension FeatureControlManager {
/// Buffers locally. Nothing is sent until `flushExposures()` is called.
public func recordExposure(_ event: FeatureControlExposureEvent) {
exposureBuffer.append(event)
}
/// Best-effort batches of 100 (server `maxItems: 100`); a failed batch is
/// dropped, never retried indefinitely (not a critical-path operation).
public func flushExposures() async {
guard !exposureBuffer.isEmpty else { return }
let events = exposureBuffer
exposureBuffer.removeAll()
for batch in events.lce_featureControlChunked(into: 100) {
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
let body = jsonBody(FeatureControlExposureBatchBody(events: batch))
_ = try? await api.request(
url: configuration.baseURL + configuration.telemetryPath,
method: .post,
body: body,
headers: headers,
timeoutInterval: configuration.requestTimeout
) as FeatureControlExposureBatchEnvelope
}
}
}
private extension Array {
func lce_featureControlChunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
/// `GET /api/feature-control/notifications` `status` query filter.
public enum FeatureControlNotificationStatus: String, Sendable {
case all, unread
}
/// One item from `NotificationListItem`.
public struct FeatureControlNotification: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let body: String
public let severity: String
public let createdAt: Date
public let read: Bool
public let ctaLabel: String?
public let ctaUrl: String?
public init(id: String, title: String, body: String, severity: String,
createdAt: Date, read: Bool, ctaLabel: String?, ctaUrl: String?) {
self.id = id
self.title = title
self.body = body
self.severity = severity
self.createdAt = createdAt
self.read = read
self.ctaLabel = ctaLabel
self.ctaUrl = ctaUrl
}
}
extension FeatureControlNotification: Decodable {
private enum CodingKeys: String, CodingKey {
case id, title, body, severity, createdAt, read, ctaLabel, ctaUrl
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawDate = try container.decode(String.self, forKey: .createdAt)
guard let date = FeatureControlDateParsing.parse(rawDate) else {
throw DecodingError.dataCorruptedError(forKey: .createdAt, in: container,
debugDescription: "Unrecognized date format: \(rawDate)")
}
self.id = try container.decode(String.self, forKey: .id)
self.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body)
self.severity = try container.decode(String.self, forKey: .severity)
self.createdAt = date
self.read = try container.decode(Bool.self, forKey: .read)
self.ctaLabel = try container.decodeIfPresent(String.self, forKey: .ctaLabel)
self.ctaUrl = try container.decodeIfPresent(String.self, forKey: .ctaUrl)
}
}
/// `GET /api/feature-control/notifications` `result` shape.
struct FeatureControlNotificationListResult: Decodable, Sendable {
let items: [FeatureControlNotification]
let nextCursor: String?
}
/// Wire envelope for the notifications list `{error, result}`.
struct FeatureControlNotificationListEnvelope: Decodable, Sendable {
let error: Bool
let result: FeatureControlNotificationListResult
}

View File

@@ -0,0 +1,76 @@
import Foundation
import LCEssentials
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
public protocol FeatureControlNotifying: Sendable {
func list(status: FeatureControlNotificationStatus, limit: Int) async throws
-> (items: [FeatureControlNotification], nextCursor: String?)
func markRead(id: String) async throws
func markAllRead() async throws
}
/// JWT-only the OpenAPI fragment declares `security: [bearerAuth]` for every
/// notifications route, no module-token alternative. Configure `configuration.auth`
/// with `FeatureControlBearerAuth`; anything else gets a `401` from the server,
/// surfaced as `FeatureControlError.unauthorized`.
public actor FeatureControlNotificationsClient: FeatureControlNotifying {
private let configuration: FeatureControlConfiguration
private let api: API
public init(configuration: FeatureControlConfiguration, api: API = .shared) {
self.configuration = configuration
self.api = api
}
public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20) async throws
-> (items: [FeatureControlNotification], nextCursor: String?) {
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
let query = "?status=\(status.rawValue)&limit=\(limit)"
do {
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
url: configuration.baseURL + configuration.notificationsPath + query,
method: .get,
headers: headers,
timeoutInterval: configuration.requestTimeout
)
return (envelope.result.items, envelope.result.nextCursor)
} catch {
throw FeatureControlErrorMapper.map(error)
}
}
public func markRead(id: String) async throws {
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
do {
let _: String = try await api.request(
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
method: .post,
headers: headers,
timeoutInterval: configuration.requestTimeout
)
} catch {
throw FeatureControlErrorMapper.map(error)
}
}
public func markAllRead() async throws {
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
do {
let _: String = try await api.request(
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
method: .post,
headers: headers,
timeoutInterval: configuration.requestTimeout
)
} catch {
throw FeatureControlErrorMapper.map(error)
}
}
}