Files
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

89 lines
2.9 KiB
Swift

//
// AdminKeychain.swift
// FTStore Admin
//
// The only place a secret is ever persisted by this app. The FTStore Admin
// session token lives here — never in UserDefaults, never in the app bundle,
// never in a cookie jar on disk.
//
// `ThisDeviceOnly` keeps the item out of iCloud Keychain and out of encrypted
// backups, so a decrypted IPA (or a restored backup) on another device carries
// no credential with it. `AfterFirstUnlock` rather than `WhenUnlocked` so a
// relaunch straight from a notification, on a device that hasn't been unlocked
// since, still finds the session.
//
import Foundation
import Security
enum AdminKeychain {
/// The app's own bundle id. Until this app existed these screens lived inside
/// FTStore Manager and used `app.ftstore.manager.admin`; that item is not
/// readable from here (no shared keychain group), so the operator signs in
/// once more and the Manager wipes its leftover on next launch.
private static let _service = "app.ftstore.admin"
/// The FTStore Admin session cookie value.
static let sessionAccount = "session-token"
@discardableResult
static func set(_ value: String?, for account: String) -> Bool {
guard let value, !value.isEmpty else {
remove(account)
return true
}
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: _service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
// Update in place when the item exists; SecItemAdd would fail with
// errSecDuplicateItem and silently leave the stale token behind.
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecSuccess { return true }
guard status == errSecItemNotFound else { return false }
var insert = query
insert.merge(attributes) { current, _ in current }
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
}
static func get(_ account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: _service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard
SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let string = String(data: data, encoding: .utf8),
!string.isEmpty
else {
return nil
}
return string
}
static func remove(_ account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: _service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}