[2026-07-resubmission] Add guest browsing flow with App Attest session for App Review resubmission

Adds a pre-login public store locator (guest session via DeviceCheck/App
Attest, keychain-backed token storage) so the app no longer forces sign-in
before showing any content, plus updated support URL metadata.
This commit is contained in:
Daniel Arantes Loverde
2026-07-30 11:35:25 -03:00
parent 3e93196b92
commit 017bd7168f
21 changed files with 1140 additions and 136 deletions

View File

@@ -57,19 +57,30 @@ struct ApiRequest: Sendable {
let requiresAuth: Bool
let queryItems: [URLQueryItem]
let body: Data?
/// Overrides `ApiConfig.baseURL` (Atomenta) for this single request used
/// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead.
let baseURLOverride: URL?
/// Sent as `Authorization: Bearer <token>` instead of the customer JWT.
/// Used for guest-session-authenticated public locator calls, which must
/// never touch `TokenStore`'s customer session.
let customBearerToken: String?
init(path: String,
method: String = "GET",
module: ApiModule = .none,
requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [],
body: Data? = nil) {
body: Data? = nil,
baseURLOverride: URL? = nil,
customBearerToken: String? = nil) {
self.path = path
self.method = method
self.module = module
self.requiresAuth = requiresAuth
self.queryItems = queryItems
self.body = body
self.baseURLOverride = baseURLOverride
self.customBearerToken = customBearerToken
}
}
@@ -120,7 +131,7 @@ final class ApiClient: @unchecked Sendable {
private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString
let method = request.method
let headers = buildHeaders(for: request)
let params = request.body
@@ -228,7 +239,7 @@ private extension ApiClient {
#endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
let url = try buildURL(path: request.path, query: request.queryItems)
let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
@@ -314,7 +325,9 @@ private extension ApiClient {
if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token
}
if request.requiresAuth, let jwt = tokenStore.jwt {
if let customBearerToken = request.customBearerToken {
headers["Authorization"] = "Bearer \(customBearerToken)"
} else if request.requiresAuth, let jwt = tokenStore.jwt {
headers["Authorization"] = "Bearer \(jwt)"
}
return headers
@@ -474,8 +487,8 @@ private extension ApiClient {
return trimmed
}
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}
components.path = components.path.appending(path)

View File

@@ -9,6 +9,11 @@ enum ApiModule: Sendable {
}
enum ApiConfig {
// Atomenta directly customer/store/cards/orders/addresses/etc. Only
// the public locator (session, locations, categories, stores-by-location,
// store detail) goes through pediFoodsBFFURL below via explicit
// baseURLOverride on those specific requests, per
// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br"
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
@@ -19,6 +24,15 @@ enum ApiConfig {
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
}
/// BFF (`PediFoods_web`) base URL. The public store locator (guest session,
/// states/cities/stores-by-location, store detail) must go through here
/// never call `baseURL` (Atomenta) directly for these, per
/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
static var pediFoodsBFFURL: URL {
let raw = ProcessInfo.processInfo.environment["PEDIFOODS_BFF_URL"] ?? "https://pedifoods.com.br"
return URL(string: raw) ?? URL(string: "https://pedifoods.com.br")!
}
static var featureControlEnvironment: String {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -35,7 +35,7 @@ final class ApiService {
do {
return try await client.send(req)
} catch let error as NetworkError {
if case .unauthorized(let message) = error {
if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
expireSession(message)
throw ApiServiceError.sessionExpired(message)
}
@@ -45,13 +45,22 @@ final class ApiService {
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req)
if isSessionExpiredEnvelope(envelope) {
if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message)
}
return envelope
}
/// A request that never carried the customer JWT (public/unauthenticated
/// calls) can never mean *the customer's* session expired an unrelated
/// error (e.g. Atomenta's module-token check) must not force-logout a
/// user, anonymous or not, just because its error code happens to
/// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md.
private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool {
req.requiresAuth && tokenStore.jwt != nil
}
private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased()
@@ -96,6 +105,16 @@ final class ApiService {
if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate
}
// If the visitor picked a state/city via the public locator before
// signing up, forward it so the backend can set it as the account's
// default city. NOTE: as of this writing Atomenta's customer create
// controller only reads name/email/phoneNumber these two fields
// are a no-op server-side until that controller is updated to
// persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity {
payload["defaultState"] = state
payload["defaultCity"] = city
}
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req)
@@ -308,7 +327,10 @@ final class ApiService {
return cached
}
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
// Open endpoint, no guest session needed, but it lives on the BFF
// domain (pedifoods.com.br), not Atomenta see
// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL)
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
@@ -447,6 +469,11 @@ final class ApiService {
return try await sendEnvelope(req)
}
func deleteAccount() async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)

View File

@@ -0,0 +1,77 @@
import Foundation
/// Local (device-only) state for the pre-login public store locator
/// see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
final class GuestLocationStore: @unchecked Sendable {
static let shared = GuestLocationStore()
private let serviceName = "com.br.pedifoods.app.guest"
private let deviceIdKey = "device_id"
private let attestationKey = "attestation"
private let attestKeyIdKey = "attest_key_id"
private let stateKey = "selected_state"
private let cityKey = "selected_city"
/// Stable per-install identifier sent as `deviceId` in the guest handshake.
var deviceId: String {
if let existing = KeychainStore.load(service: serviceName, key: deviceIdKey) {
return existing
}
let generated = UUID().uuidString
KeychainStore.save(generated, service: serviceName, key: deviceIdKey)
return generated
}
/// Fallback-only placeholder (backend just checks non-empty) for
/// environments that can't run real App Attest Simulator, or non-iOS.
/// Real devices use DCAppAttestService via GuestSessionService instead.
var attestationPlaceholder: String {
if let existing = KeychainStore.load(service: serviceName, key: attestationKey) {
return existing
}
let generated = UUID().uuidString
KeychainStore.save(generated, service: serviceName, key: attestationKey)
return generated
}
/// App Attest key ID already registered with the backend for this
/// device, if any. Present -> use it to sign assertions; absent -> this
/// device needs to attest a freshly generated key first.
var appAttestKeyId: String? {
get { KeychainStore.load(service: serviceName, key: attestKeyIdKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: attestKeyIdKey)
} else {
KeychainStore.delete(service: serviceName, key: attestKeyIdKey)
}
}
}
var selectedState: String? {
get { KeychainStore.load(service: serviceName, key: stateKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: stateKey)
} else {
KeychainStore.delete(service: serviceName, key: stateKey)
}
}
}
var selectedCity: String? {
get { KeychainStore.load(service: serviceName, key: cityKey) }
set {
if let newValue {
KeychainStore.save(newValue, service: serviceName, key: cityKey)
} else {
KeychainStore.delete(service: serviceName, key: cityKey)
}
}
}
func clearSelectedLocation() {
KeychainStore.delete(service: serviceName, key: stateKey)
KeychainStore.delete(service: serviceName, key: cityKey)
}
}

View File

@@ -0,0 +1,222 @@
import Foundation
#if os(iOS)
import DeviceCheck
import CryptoKit
#endif
private struct ChallengeResult: Decodable, Sendable {
let challenge: String
}
/// Fields differ by handshake step Optional properties are omitted from
/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so
/// this one struct covers both the fresh-attestation and assertion payloads.
private struct GuestSessionAttestPayload: Encodable, Sendable {
let platform: String
let deviceId: String
let challenge: String
let keyId: String?
let attestation: String?
let assertion: String?
}
private struct GuestSessionPlaceholderPayload: Encodable, Sendable {
let platform: String
let deviceId: String
let attestation: String
}
/// Issues and caches the short-lived (15min) guest JWT used by the pre-login
/// public store locator. Deliberately separate from ApiService/TokenStore
/// a guest-session 401 must never be treated as the customer session
/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md).
///
/// iOS uses real App Attest (2-call protocol: challenge, then
/// attest-a-fresh-key the first time per device or sign-an-assertion with
/// the already-registered key every time after). If the server doesn't
/// recognize a previously-registered key (e.g. it lost its in-memory
/// registration), the assertion call fails and this falls back to
/// re-attesting with a brand new key rather than leaving the guest stuck.
actor GuestSessionService {
static let shared = GuestSessionService()
private var cachedToken: String?
private var expiresAt: Date?
private let client: ApiClient
private let store: GuestLocationStore
// Actors are reentrant across `await` points without this, two
// concurrent callers with no cached token yet (very possible: the
// location picker and Home's guest store load can both need a guest
// session near launch) would each independently run the App Attest
// flow, racing each other's key registration against the server. This
// makes every caller share the one in-flight handshake instead.
private var inFlightRefresh: Task<String, Error>?
init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) {
self.client = client
self.store = store
}
func validToken() async throws -> String {
if let cachedToken, let expiresAt, expiresAt > Date() {
return cachedToken
}
return try await refreshToken()
}
/// Forces a fresh handshake, used for the silent retry-on-401 flow.
func invalidateAndRefresh() async throws -> String {
cachedToken = nil
expiresAt = nil
inFlightRefresh = nil
return try await refreshToken()
}
private func refreshToken() async throws -> String {
if let inFlightRefresh {
return try await inFlightRefresh.value
}
let task = Task { try await performRefresh() }
inFlightRefresh = task
defer { inFlightRefresh = nil }
return try await task.value
}
private func performRefresh() async throws -> String {
#if os(iOS)
return try await refreshTokenWithAppAttest()
#else
return try await refreshTokenWithPlaceholder()
#endif
}
#if os(iOS)
private func refreshTokenWithAppAttest() async throws -> String {
guard DCAppAttestService.shared.isSupported else {
// Simulator can never support App Attest (hardware limitation,
// not environment-specific) server has its own documented
// bypass for this case, gated by an admin toggle server-side.
let challenge = try await fetchChallenge()
return try await handshakeWithSimulatorBypass(challenge: challenge)
}
if let existingKeyId = store.appAttestKeyId {
do {
let challenge = try await fetchChallenge()
return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge)
} catch let error as NetworkError where isKeyRejectedByServer(error) {
// Server explicitly rejected this key (403
// APP_ATTEST_VERIFICATION_FAILED e.g. it lost the
// credential registration) re-attest with a new key. Any
// other error (network blip, timeout, decode issue) must
// NOT wipe a perfectly valid registered key.
store.appAttestKeyId = nil
}
}
let challenge = try await fetchChallenge()
return try await handshakeWithFreshAttestation(challenge: challenge)
}
private func isKeyRejectedByServer(_ error: NetworkError) -> Bool {
if case .httpError(403, _) = error { return true }
return false
}
private func fetchChallenge() async throws -> String {
let req = ApiRequest(
path: "/api/public/attest/challenge",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)],
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<ChallengeResult> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result.challenge
}
private func handshakeWithFreshAttestation(challenge: String) async throws -> String {
let keyId = try await DCAppAttestService.shared.generateKey()
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
let attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: keyId,
attestation: attestationObject.base64EncodedString(),
assertion: nil
)
let token = try await sendSessionRequest(body: JSONEncoder().encode(payload))
store.appAttestKeyId = keyId
return token
}
/// Simulator can never run real App Attest server accepts this literal
/// bypass value instead, gated by its own admin toggle (403
/// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug).
private func handshakeWithSimulatorBypass(challenge: String) async throws -> String {
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: nil,
attestation: "SIMULATOR_BYPASS",
assertion: nil
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String {
let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8)))
let assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash)
let payload = GuestSessionAttestPayload(
platform: "ios",
deviceId: store.deviceId,
challenge: challenge,
keyId: nil,
attestation: nil,
assertion: assertionObject.base64EncodedString()
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
#endif
/// Only reachable on non-iOS builds (this app's macOS target is test-only,
/// never a real distribution target no documented contract for it, so
/// this stays a best-effort placeholder rather than matching a real spec.
private func refreshTokenWithPlaceholder() async throws -> String {
let payload = GuestSessionPlaceholderPayload(
platform: "ios",
deviceId: store.deviceId,
attestation: store.attestationPlaceholder
)
return try await sendSessionRequest(body: JSONEncoder().encode(payload))
}
private func sendSessionRequest(body: Data) async throws -> String {
let req = ApiRequest(
path: "/api/public/session",
method: "POST",
module: .none,
requiresAuth: false,
body: body,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<GuestSessionResult> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
cachedToken = result.guestToken
// Refresh a bit early so a request started near expiry doesn't race the server's own clock.
expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30)
return result.guestToken
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
#if os(iOS)
import Security
#endif
/// Small generic Keychain wrapper (iOS) with a UserDefaults fallback on other
/// platforms (macOS test target), namespaced by `service`+`key`.
enum KeychainStore {
static func save(_ value: String, service: String, key: String) {
#if os(iOS)
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = data
SecItemAdd(attributes as CFDictionary, nil)
#else
UserDefaults.standard.set(value, forKey: "\(service).\(key)")
#endif
}
static func load(service: String, key: String) -> String? {
#if os(iOS)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
#else
UserDefaults.standard.string(forKey: "\(service).\(key)")
#endif
}
static func delete(service: String, key: String) {
#if os(iOS)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
#else
UserDefaults.standard.removeObject(forKey: "\(service).\(key)")
#endif
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
// DTOs for the pre-login public store locator (pedifoods.com.br BFF).
// See docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md for the
// exact contract these mirror.
struct GuestSessionResult: Decodable, Sendable {
let guestToken: String
let expiresIn: Int
}
/// `GET /api/public/locations` returns states+cities in one call, keyed by
/// state UF with an array of city names e.g. `{"SP": ["Aguaí", "Campinas"]}`.
typealias PublicLocationsResult = [String: [String]]
struct PublicStoreListItem: Decodable, Sendable, Identifiable {
let id: String
let storeId: String?
let name: String?
let logo: String?
let cover: String?
let category: String?
let isOpen: Bool?
let statusLabel: String?
let nextOpenLabel: String?
let rating: Double?
let totalReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let minOrder: Double?
}
struct PublicStoreDetail: Decodable, Sendable {
let id: String
let storeId: String?
let slug: String?
let fantasyName: String?
let razaoSocial: String?
let logo: String?
let cover: String?
let specialty: String?
let phone: String?
let responsiblePhone: String?
let address: String?
let neighborhood: String?
let city: String?
let state: String?
let zipcode: String?
let isOpen: Bool?
let statusLabel: String?
let nextOpenLabel: String?
let averageRate: Double?
let totalReviews: Int?
let deliveryTime: String?
let deliveryFee: Double?
let deliveryPrice: Double?
let minOrder: Double?
let acceptPix: Bool?
}
// Maps the public (anonymous) store-detail projection onto the same models
// StoreDetailView already renders for authenticated users, so the view
// itself doesn't need to know which source the data came from.
extension StoreInfoResult {
init(publicDetail: PublicStoreDetail) {
isOpen = publicDetail.isOpen
statusLabel = publicDetail.statusLabel
fantasyName = publicDetail.fantasyName
phone = publicDetail.phone
whatsapp = nil
logo = publicDetail.logo
cover = publicDetail.cover
deliveryTime = publicDetail.deliveryTime
minOrder = publicDetail.minOrder
address = StoreAddressInfo(publicDetail: publicDetail)
paymentMethods = StorePaymentMethodsInfo(acceptPix: publicDetail.acceptPix)
}
}
extension StoreAddressInfo {
init(publicDetail: PublicStoreDetail) {
street = publicDetail.address
number = nil
neighborhood = publicDetail.neighborhood
city = publicDetail.city
state = publicDetail.state
zipCode = publicDetail.zipcode
latitude = nil
longitude = nil
}
}
extension StorePaymentMethodsInfo {
/// The public projection only exposes whether Pix is accepted every
/// other payment flag is unknown until the user is authenticated and can
/// see it via the real store-info call.
init(acceptPix: Bool?) {
paymentOnDelivery = nil
paymentOnPickup = nil
self.acceptPix = acceptPix
acceptCash = nil
acceptCreditCard = nil
acceptDebitCard = nil
acceptCreditVisa = nil
acceptCreditMaster = nil
acceptCreditElo = nil
acceptCreditAmex = nil
acceptCreditHipercard = nil
acceptDebitVisa = nil
acceptDebitMaster = nil
acceptDebitElo = nil
acceptVoucherAlelo = nil
acceptVoucherSodexo = nil
acceptVoucherTicket = nil
acceptVoucherVR = nil
}
}

View File

@@ -0,0 +1,110 @@
import Foundation
/// Pre-login public store locator states/cities/stores-by-location, and
/// store detail. Always talks to `pedifoods.com.br` (the BFF), never
/// `atomenta.com.br` directly. See
/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md.
///
/// Deliberately does not go through ApiService.sendEnvelope that treats
/// any session-expired-shaped response as the *customer* session expiring
/// (clears TokenStore, forces logout). A guest-session 401 here means the
/// short-lived guest JWT expired and must be silently refreshed instead.
final class PublicLocationService: @unchecked Sendable {
static let shared = PublicLocationService()
private let client: ApiClient
private let guestSession: GuestSessionService
init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) {
self.client = client
self.guestSession = guestSession
}
/// States + their cities in one call keyed by UF, e.g. `{"SP": [...]}`.
func fetchLocations() async throws -> PublicLocationsResult {
try await sendGuestAuthed(path: "/api/public/locations")
}
func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] {
let query = [
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "city", value: city)
]
return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query)
}
/// No guest token this route is fully public/unauthenticated per the doc.
func fetchStoreDetail(identifier: String) async throws -> PublicStoreDetail {
let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier
let req = ApiRequest(
path: "/api/public/store/\(encodedIdentifier)",
method: "GET",
module: .none,
requiresAuth: false,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<PublicStoreDetail> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
/// No guest token same open pattern as fetchStoreDetail. Reuses the
/// existing StoreCatalogCategory/StoreCatalogProduct models directly:
/// the public response is the same category+products shape (with extra
/// computed inventory/stockStatus fields the decoder just ignores).
func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] {
let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId
let req = ApiRequest(
path: "/api/public/store/\(encodedStoreId)/products",
method: "GET",
module: .none,
requiresAuth: false,
baseURLOverride: ApiConfig.pediFoodsBFFURL
)
let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
private func sendGuestAuthed<T: Decodable & Sendable>(path: String, query: [URLQueryItem] = []) async throws -> T {
let token = try await guestSession.validToken()
do {
return try await performGuestRequest(path: path, query: query, token: token)
} catch let error as NetworkError where isGuestSessionExpired(error) {
let refreshed = try await guestSession.invalidateAndRefresh()
return try await performGuestRequest(path: path, query: query, token: refreshed)
}
}
private func performGuestRequest<T: Decodable & Sendable>(path: String, query: [URLQueryItem], token: String) async throws -> T {
let req = ApiRequest(
path: path,
method: "GET",
module: .none,
requiresAuth: false,
queryItems: query,
baseURLOverride: ApiConfig.pediFoodsBFFURL,
customBearerToken: token
)
let envelope: ApiEnvelope<T> = try await client.send(req)
guard envelope.error == false, let result = envelope.result else {
throw NetworkError.invalidResponse
}
return result
}
private func isGuestSessionExpired(_ error: NetworkError) -> Bool {
switch error {
case .unauthorized:
return true
case .httpError(let statusCode, _):
return statusCode == 401
default:
return false
}
}
}

View File

@@ -1,7 +1,4 @@
import Foundation
#if os(iOS)
import Security
#endif
protocol TokenStore: AnyObject {
var jwt: String? { get set }
@@ -10,12 +7,13 @@ protocol TokenStore: AnyObject {
final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt"
private let serviceName = "com.br.pedifoods.app.auth"
private let defaults = UserDefaults.standard
var jwt: String? {
get {
#if os(iOS)
if let keychainValue = loadKeychainValue(for: key) {
if let keychainValue = KeychainStore.load(service: serviceName, key: key) {
return keychainValue
}
#endif
@@ -24,9 +22,9 @@ final class DefaultTokenStore: TokenStore {
set {
#if os(iOS)
if let newValue {
saveKeychainValue(newValue, for: key)
KeychainStore.save(newValue, service: serviceName, key: key)
} else {
deleteKeychainValue(for: key)
KeychainStore.delete(service: serviceName, key: key)
}
#endif
defaults.set(newValue, forKey: key)
@@ -35,57 +33,8 @@ final class DefaultTokenStore: TokenStore {
func clear() {
#if os(iOS)
deleteKeychainValue(for: key)
KeychainStore.delete(service: serviceName, key: key)
#endif
defaults.removeObject(forKey: key)
}
#if os(iOS)
private var serviceName: String { "com.br.pedifoods.app.auth" }
private func saveKeychainValue(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemAdd(attributes as CFDictionary, nil)
}
private func loadKeychainValue(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func deleteKeychainValue(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
#endif
}