[lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests

- FeatureControlManager/NotificationsClient: pass debug: false at every
  api.request(...) call site — the API default (debug: true) was printing
  Authorization: Bearer <jwt> on every request, release builds included.
- Package.swift: drop macOS/tvOS from platforms — LCEssentials.API (used by
  LCFeatureControl) is iOS/watchOS-only and the vendored xcframework has no
  macOS/tvOS slice.
- FeatureControlManager: coalesce concurrent evaluate() calls on a cold cache
  into a single in-flight request per key instead of firing one per caller.
- FeatureControlManager/NotificationsClient: treat a 200 response carrying
  {"error": true} as a failure instead of caching/returning it as success.
- FeatureControlNotificationsClient: thread a cursor param through list() so
  the already-decoded nextCursor can actually be used to page.
- Tests: 9 new tests (coalescing, envelope-error-on-200, cursor param, a real
  object payload decoded through a full evaluate response, date-decode
  failures) and removed the 7 remaining force-unwraps in test scaffolding.
- Documentation/FeatureControl.md: new guide for the LCFeatureControl product,
  cross-linked from README.md and Extensions.md.
This commit is contained in:
Daniel Arantes Loverde
2026-09-16 13:21:10 -03:00
parent 2ba487a6e8
commit c6e9803c75
12 changed files with 392 additions and 69 deletions

View File

@@ -2,7 +2,8 @@
Foundation, value-type, string, collection, numeric, date, and crypto helpers, plus
the `LCEssentials` namespace itself. UIKit extensions live in
[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md).
[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md); the
`LCFeatureControl` sub-product in [FeatureControl.md](FeatureControl.md).
Every section is a collapsible block — click a heading to expand it.

View File

@@ -0,0 +1,175 @@
# LCEssentials — Feature Control
A typed Swift client for Atomenta's Feature Control API: flag evaluation with a
TTL cache and safe-degrade fallback, an in-app notifications inbox, and batched
exposure telemetry. Lives in its own SPM product, `LCFeatureControl`, not in
`LCEssentials` itself — an optional sub-package that target-depends on
`LCEssentials` (same shape as [`LCECryptoKit`](Extensions.md), see its section
there for the sub-SPM rationale).
Every section is a collapsible block — click a heading to expand it.
## Contents
- [Setup](#setup)
- [Evaluating flags](#evaluating-flags)
- [Notifications inbox](#notifications-inbox)
- [Exposure telemetry](#exposure-telemetry)
- [Errors](#errors)
---
## Setup
Add the product explicitly alongside `LCEssentials` to a consumer's `Package.swift`:
```swift
.product(name: "LCEssentials", package: "LCEssentials"),
.product(name: "LCFeatureControl", package: "LCEssentials"),
```
```swift
import LCFeatureControl
```
<details>
<summary><b>FeatureControlConfiguration</b> — wiring for one module deployment</summary>
```swift
let configuration = FeatureControlConfiguration(
baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlBearerAuth { await session.currentJWT() }
)
```
| Property | Default | Notes |
| --- | --- | --- |
| `cacheTTL` | `45` seconds | In-memory client-side cache window |
| `requestTimeout` | `3` seconds | Per-request timeout budget |
| `evaluatePath` / `notificationsPath` / `telemetryPath` | `/api/feature-control/...` | Override only for a non-default BFF mount |
</details>
<details>
<summary><b>Auth strategies</b> — <code>FeatureControlAuthorizing</code></summary>
No `Atomenta-Token` (module token) type exists in this package on purpose — that
token must never be embedded in a customer-facing app. Pick one:
```swift
// Internal/admin app hitting Atomenta directly with a panel-role JWT.
let auth = FeatureControlBearerAuth { await session.currentJWT() }
// Customer app calling its own BFF, which enforces its own auth
// adds exactly the given headers, never synthesizes Authorization.
let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": sessionToken])
```
</details>
## Evaluating flags
<details open>
<summary><b>FeatureControlManager</b> — cache, safe-degrade, invalidation</summary>
```swift
let manager = FeatureControlManager(
configuration: configuration,
defaults: ["fc.checkout_v2": FeatureControlFlag(enabled: false, variant: nil,
payload: nil, reason: "default")]
)
let context = FeatureControlContext(subjectType: .customer, subjectId: user.id,
storeId: store.id, platform: "ios",
appVersion: appVersion)
let snapshot = await manager.evaluate(keys: ["fc.checkout_v2"], context: context)
snapshot.flags["fc.checkout_v2"]?.enabled // Bool
```
`evaluate(keys:context:)` **never throws** — fresh fetch → stale cache → the
`defaults` passed at init, in that order. Use it everywhere a flag gates real
product behaviour. `evaluateOrThrow(keys:context:)` surfaces the real failure
instead, for admin/debug tooling only.
Concurrent calls for the same key on a cold cache are coalesced onto a single
in-flight request — three callers evaluating the same key at once produce one
network call, not three.
```swift
await manager.invalidateCache() // call on user/store change
```
The cache key is `environment + subjectId + sortedKeys + platform + appVersion`
— it does **not** include `storeId` or `attributes`. If a subject's flags can
legitimately differ by store, call `invalidateCache()` on store switch.
</details>
## Notifications inbox
<details>
<summary><b>FeatureControlNotificationsClient</b> — JWT-only</summary>
The OpenAPI fragment declares `security: [bearerAuth]` for every notifications
route, no module-token alternative — configure with `FeatureControlBearerAuth`.
```swift
let client = FeatureControlNotificationsClient(configuration: configuration)
let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
let (more, _) = try await client.list(status: .unread, limit: 20, cursor: nextCursor)
try await client.markRead(id: notification.id)
try await client.markAllRead()
```
</details>
## Exposure telemetry
<details>
<summary><b>recordExposure(_:)</b> / <b>flushExposures()</b> — best-effort, batched</summary>
```swift
await manager.recordExposure(FeatureControlExposureEvent(
featureKey: "fc.checkout_v2", variant: "on",
subjectType: .customer, storeId: store.id
))
// ... later, e.g. on app background or a periodic timer:
await manager.flushExposures()
```
Buffers locally — nothing is sent until `flushExposures()` is called. Splits
into batches of ≤100 (server `maxItems: 100`). Not a critical-path operation: a
failed batch is dropped, never retried indefinitely.
</details>
## Errors
<details>
<summary><b>FeatureControlError</b> — from <code>evaluateOrThrow</code> and the notifications client</summary>
```swift
public enum FeatureControlError: Error, Sendable, Equatable {
case invalidContext(code: String) // 400
case unauthorized(code: String) // 401
case forbidden(code: String) // 403
case rateLimited(code: String) // 429
case server(code: String, status: Int)
case transport(message: String) // decode failure / non-HTTP error
}
```
The safe `evaluate(...)` path never surfaces this — it degrades to cache/defaults
instead. `evaluateOrThrow` also throws `.server(code:status: 200)` if the server
responds `200` with an `{"error": true}` envelope (a degraded-but-200 response).
</details>
---
See [Extensions.md](Extensions.md) for the rest of `LCEssentials`' Foundation
helpers, including the sibling `LCECryptoKit` sub-product.