From 3de2e96dcbf52a607badf8ef4fcb5ee9d34dedcf Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 28 Sep 2026 22:14:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Apple=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EA=B3=BC=20=EC=84=9C=EB=B2=84=20=EC=82=AC=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apple 로그인 및 StoreKit 기반 서버 권한 검증 추가 - Redis 기반 무료·Pro 일일 한도와 동시 요청 제어 적용 - 세션 갱신·로그아웃·계정 삭제 및 보안 테스트 추가 - Release 프록시 고정과 개인정보 처리방침·배포 문서 정비 --- .github/workflows/ci.yml | 32 ++ Config/Paxo.entitlements | 4 + .../xcshareddata/xcschemes/Paxo.xcscheme | 3 - Paxo/AGENTS.md | 7 +- Paxo/AI/GeminiService.swift | 190 +++++-- Paxo/AI/ProxySessionManager.swift | 377 ++++++++++++ Paxo/AppState.swift | 375 ++++++++++-- Paxo/DefaultConfig.swift | 8 +- Paxo/Storage/KeychainHelper.swift | 1 + Paxo/Store/StoreManager.swift | 23 +- Paxo/Store/UsagePolicy.swift | 4 + Paxo/Store/UsageTracker.swift | 44 -- Paxo/UI/MenuContentView.swift | 82 ++- Paxo/UI/PaywallView.swift | 19 +- Paxo/UI/ResultView.swift | 2 + Paxo/UI/SettingsView.swift | 50 +- Paxo/UI/ToastController.swift | 2 +- PaxoTests/GeminiServiceTests.swift | 23 +- PaxoTests/ProxySessionManagerTests.swift | 185 ++++++ PaxoTests/UsageTrackerTests.swift | 74 --- README.md | 10 +- docs/architecture.md | 24 +- docs/decisions.md | 3 + docs/domain.md | 6 +- proxy-vercel/.env.example | 20 + proxy-vercel/AGENTS.md | 51 +- proxy-vercel/README.md | 83 ++- proxy-vercel/api/account.js | 50 ++ proxy-vercel/api/generate.js | 372 +++++++----- proxy-vercel/api/privacy.js | 60 +- proxy-vercel/api/session-challenge.js | 32 ++ proxy-vercel/api/session-logout.js | 35 ++ proxy-vercel/api/session-refresh.js | 92 +++ proxy-vercel/api/session.js | 70 +++ .../certs/apple-root-certificates.pem | 108 ++++ proxy-vercel/lib/apple-sign-in.js | 183 ++++++ proxy-vercel/lib/apple-store.js | 132 +++++ proxy-vercel/lib/config.js | 114 ++++ proxy-vercel/lib/errors.js | 15 + proxy-vercel/lib/http.js | 15 + proxy-vercel/lib/redis-store.js | 278 +++++++++ proxy-vercel/lib/security.js | 109 ++++ proxy-vercel/lib/session-service.js | 100 ++++ proxy-vercel/package-lock.json | 535 ++++++++++++++++++ proxy-vercel/package.json | 17 + proxy-vercel/test/apple-sign-in.test.js | 66 +++ proxy-vercel/test/apple-store.test.js | 124 ++++ proxy-vercel/test/auth-lifecycle.test.js | 149 +++++ proxy-vercel/test/config-security.test.js | 108 ++++ proxy-vercel/test/generate.test.js | 181 ++++++ proxy-vercel/test/helpers.js | 229 ++++++++ proxy-vercel/test/session.test.js | 140 +++++ proxy-vercel/vercel.json | 7 +- proxy/README.md | 60 -- proxy/worker.js | 154 ----- proxy/wrangler.toml | 20 - 56 files changed, 4486 insertions(+), 771 deletions(-) create mode 100644 Paxo/AI/ProxySessionManager.swift create mode 100644 Paxo/Store/UsagePolicy.swift delete mode 100644 Paxo/Store/UsageTracker.swift create mode 100644 PaxoTests/ProxySessionManagerTests.swift delete mode 100644 PaxoTests/UsageTrackerTests.swift create mode 100644 proxy-vercel/.env.example create mode 100644 proxy-vercel/api/account.js create mode 100644 proxy-vercel/api/session-challenge.js create mode 100644 proxy-vercel/api/session-logout.js create mode 100644 proxy-vercel/api/session-refresh.js create mode 100644 proxy-vercel/api/session.js create mode 100644 proxy-vercel/certs/apple-root-certificates.pem create mode 100644 proxy-vercel/lib/apple-sign-in.js create mode 100644 proxy-vercel/lib/apple-store.js create mode 100644 proxy-vercel/lib/config.js create mode 100644 proxy-vercel/lib/errors.js create mode 100644 proxy-vercel/lib/http.js create mode 100644 proxy-vercel/lib/redis-store.js create mode 100644 proxy-vercel/lib/security.js create mode 100644 proxy-vercel/lib/session-service.js create mode 100644 proxy-vercel/package-lock.json create mode 100644 proxy-vercel/package.json create mode 100644 proxy-vercel/test/apple-sign-in.test.js create mode 100644 proxy-vercel/test/apple-store.test.js create mode 100644 proxy-vercel/test/auth-lifecycle.test.js create mode 100644 proxy-vercel/test/config-security.test.js create mode 100644 proxy-vercel/test/generate.test.js create mode 100644 proxy-vercel/test/helpers.js create mode 100644 proxy-vercel/test/session.test.js delete mode 100644 proxy/README.md delete mode 100644 proxy/worker.js delete mode 100644 proxy/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 406d5e4..5d6cab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,38 @@ jobs: fi echo "✓ 템플릿 위치 정상" + - name: 알려진 시크릿 형식 검사 + run: | + if git grep -nE 'AIza[0-9A-Za-z_-]{30,}|-----BEGIN (EC |RSA )?PRIVATE KEY-----' -- . ':!proxy-vercel/test/*'; then + echo "::error::API 키 또는 개인 키로 보이는 값이 추적 파일에 있습니다" + exit 1 + fi + echo "✓ 알려진 시크릿 형식 없음" + + proxy: + name: 프록시 테스트 + runs-on: ubuntu-latest + defaults: + run: + working-directory: proxy-vercel + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: proxy-vercel/package-lock.json + + - name: 의존성 설치 + run: npm ci + + - name: 문법 검사 + run: npm run check + + - name: 단위 테스트 + run: npm test + format: name: swift-format runs-on: macos-15 diff --git a/Config/Paxo.entitlements b/Config/Paxo.entitlements index ee95ab7..cd2171f 100644 --- a/Config/Paxo.entitlements +++ b/Config/Paxo.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default + com.apple.security.app-sandbox com.apple.security.network.client diff --git a/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme b/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme index 8f4b64c..cefc106 100644 --- a/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme +++ b/Paxo.xcodeproj/xcshareddata/xcschemes/Paxo.xcscheme @@ -61,9 +61,6 @@ ReferencedContainer = "container:Paxo.xcodeproj"> - - String { - try await generate(prompt: Prompts.answer(preset: preset), imageData: imageData) + func answer( + imageData: Data, + preset: SubjectPreset, + sessionToken: String, + solveID: UUID + ) async throws -> GenerationResult { + try await generate( + kind: .answer, + prompt: Prompts.answer(preset: preset), + imageData: imageData, + sessionToken: sessionToken, + solveID: solveID + ) } - func explain(imageData: Data, answer: String, preset: SubjectPreset) async throws -> String { - try await generate(prompt: Prompts.explanation(preset: preset, answer: answer), imageData: imageData) + func explain( + imageData: Data, + answer: String, + preset: SubjectPreset, + sessionToken: String, + solveID: UUID + ) async throws -> GenerationResult { + try await generate( + kind: .explanation, + prompt: Prompts.explanation(preset: preset, answer: answer), + imageData: imageData, + sessionToken: sessionToken, + solveID: solveID + ) } - /// 우선순위: 사용자가 설정한 프록시 → 내장 기본 프록시 - private var effectiveProxyURL: String { - let user = proxyURL.trimmingCharacters(in: .whitespacesAndNewlines) - return user.isEmpty ? DefaultConfig.proxyURL : user - } - - func makeRequest() throws -> URLRequest { + func makeRequest(sessionToken: String) throws -> URLRequest { #if DEBUG if useDirectGemini { return try makeDirectRequest() } + let candidate = proxyURL.trimmingCharacters(in: .whitespacesAndNewlines) + let proxy = candidate.isEmpty ? DefaultConfig.proxyURL : candidate + #else + let proxy = DefaultConfig.proxyURL #endif - - let proxy = effectiveProxyURL let base = proxy.hasSuffix("/") ? String(proxy.dropLast()) : proxy - guard let url = URL(string: base + "/generate") else { + guard let url = URL(string: base + "/generate"), url.scheme == "https" else { throw GeminiError.badURL } var request = URLRequest(url: url) - request.setValue(deviceID, forHTTPHeaderField: "x-paxo-device") + request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization") request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") return request } #if DEBUG private func makeDirectRequest() throws -> URLRequest { - guard !apiKey.isEmpty else { - throw GeminiError.missingKey - } + guard !apiKey.isEmpty else { throw GeminiError.missingKey } guard let url = URL( string: "https://generativelanguage.googleapis.com/v1beta/models/\(Self.model):generateContent" ) - else { - throw GeminiError.badURL - } + else { throw GeminiError.badURL } var request = URLRequest(url: url) request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") return request } #endif - private func generate(prompt: String, imageData: Data) async throws -> String { - var request = try makeRequest() + private func generate( + kind: GenerationKind, + prompt: String, + imageData: Data, + sessionToken: String, + solveID: UUID + ) async throws -> GenerationResult { + var request = try makeRequest(sessionToken: sessionToken) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let body: [String: Any] = [ + var body: [String: Any] = [ "contents": [ [ "parts": [ @@ -93,6 +115,17 @@ struct GeminiService { ] ] ] + #if DEBUG + if useDirectGemini { + body["generationConfig"] = ["maxOutputTokens": kind == .answer ? 256 : 1024] + } else { + body["kind"] = kind.rawValue + body["solveId"] = solveID.uuidString.lowercased() + } + #else + body["kind"] = kind.rawValue + body["solveId"] = solveID.uuidString.lowercased() + #endif request.httpBody = try JSONSerialization.data(withJSONObject: body) let data: Data @@ -103,12 +136,20 @@ struct GeminiService { throw GeminiError.network(error) } - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - if code == 429 { - throw GeminiError.rateLimited + guard let http = response as? HTTPURLResponse else { throw GeminiError.http(-1, nil) } + guard http.statusCode == 200 else { + let code = Self.serverCode(from: data) + switch (http.statusCode, code) { + case (401, _): throw GeminiError.invalidSession + case (429, "daily_limit"): + if http.value(forHTTPHeaderField: "x-paxo-tier") == ProxyTier.pro.rawValue { + throw GeminiError.proDailyLimit + } + throw GeminiError.freeDailyLimit + case (429, _): throw GeminiError.rateLimited + case (502, _), (503, _), (504, _): throw GeminiError.serviceUnavailable + default: throw GeminiError.http(http.statusCode, Self.serverMessage(from: data)) } - throw GeminiError.http(code, Self.serverMessage(from: data)) } let decoded = try JSONDecoder().decode(GenerateContentResponse.self, from: data) @@ -120,20 +161,38 @@ struct GeminiService { .compactMap(\.text) .joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { throw GeminiError.emptyResponse } + return GenerationResult( + remainingToday: http.value(forHTTPHeaderField: "x-paxo-remaining").flatMap(Int.init), + resetAt: Self.date(from: http.value(forHTTPHeaderField: "x-paxo-reset-at")), + text: text, + tier: http.value(forHTTPHeaderField: "x-paxo-tier").flatMap(ProxyTier.init) + ) + } - guard !text.isEmpty else { - throw GeminiError.emptyResponse - } - return text + private static func serverCode(from data: Data) -> String? { + errorEnvelope(from: data)?.error?.code } private static func serverMessage(from data: Data) -> String? { - struct ErrorEnvelope: Decodable { - struct Inner: Decodable { let message: String? } - let error: Inner? - } - return (try? JSONDecoder().decode(ErrorEnvelope.self, from: data))?.error?.message + errorEnvelope(from: data)?.error?.message } + + private static func errorEnvelope(from data: Data) -> ErrorEnvelope? { + try? JSONDecoder().decode(ErrorEnvelope.self, from: data) + } + + private static func date(from value: String?) -> Date? { + guard let value else { return nil } + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return withFraction.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } +} + +private enum GenerationKind: String { + case answer + case explanation } private struct GenerateContentResponse: Decodable { @@ -143,29 +202,42 @@ private struct GenerateContentResponse: Decodable { let candidates: [Candidate]? } +private struct ErrorEnvelope: Decodable { + struct Inner: Decodable { + let code: String? + let message: String? + } + + let error: Inner? +} + enum GeminiError: LocalizedError { - case missingKey case badURL - case http(Int, String?) - case rateLimited case emptyResponse + case freeDailyLimit + case http(Int, String?) + case invalidSession + case missingKey case network(URLError) + case proDailyLimit + case rateLimited + case serviceUnavailable var errorDescription: String? { switch self { - case .missingKey: - return "AI 연결이 설정되지 않았습니다. 설정에서 프록시 URL 또는 API 키를 입력해주세요." case .badURL: return "AI 연결 설정에 문제가 있습니다. 잠시 후 다시 시도해주세요." - case .http(let code, let message): - if let message, !message.isEmpty { - return message - } - return "서버 오류가 발생했습니다. (HTTP \(code)) 잠시 후 다시 시도해주세요." - case .rateLimited: - return "오늘 사용량을 다 썼거나 요청이 너무 많습니다. 잠시 후 다시 시도해주세요." case .emptyResponse: return "AI 응답이 비어 있습니다. 다시 시도해주세요." + case .freeDailyLimit: + return "오늘 무료 풀이 3회를 모두 사용했습니다. Pro로 업그레이드하거나 내일 다시 이용해주세요." + case .http(let code, let message): + if let message, !message.isEmpty { return message } + return "서버 오류가 발생했습니다. (HTTP \(code)) 잠시 후 다시 시도해주세요." + case .invalidSession: + return "로그인 세션이 만료되었습니다. 다시 시도해주세요." + case .missingKey: + return "개발용 Gemini API 키가 설정되지 않았습니다." case .network(let error): switch error.code { case .notConnectedToInternet, .dataNotAllowed: @@ -177,6 +249,12 @@ enum GeminiError: LocalizedError { default: return "네트워크 오류가 발생했습니다. 다시 시도해주세요." } + case .proDailyLimit: + return "Pro의 일일 안전 한도 100회를 모두 사용했습니다. 내일 다시 이용해주세요." + case .rateLimited: + return "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." + case .serviceUnavailable: + return "AI 서버를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." } } } diff --git a/Paxo/AI/ProxySessionManager.swift b/Paxo/AI/ProxySessionManager.swift new file mode 100644 index 0000000..53fb82f --- /dev/null +++ b/Paxo/AI/ProxySessionManager.swift @@ -0,0 +1,377 @@ +import CryptoKit +import Foundation +import StoreKit + +enum ProxyTier: String, Codable { + case free + case pro + + var dailyLimit: Int { + switch self { + case .free: UsagePolicy.dailyFreeLimit + case .pro: UsagePolicy.dailyProLimit + } + } +} + +struct ProxySession: Decodable { + let expiresAt: Date + let refreshToken: String + let remainingToday: Int + let resetAt: Date + let sessionToken: String + let tier: ProxyTier +} + +struct StoreKitProof: Codable { + enum Kind: String, Codable { + case appTransaction + case transaction + } + + let jws: String + let type: Kind +} + +protocol StoreKitProofProviding { + func proof() async -> StoreKitProof? +} + +protocol HTTPDataLoading { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: HTTPDataLoading {} + +protocol ProxyCredentialStoring { + func loadRefreshToken() -> String? + func saveRefreshToken(_ value: String) +} + +struct KeychainProxyCredentialStore: ProxyCredentialStoring { + private let key = "proxy-refresh-token" + + func loadRefreshToken() -> String? { + KeychainHelper.load(key: key) + } + + func saveRefreshToken(_ value: String) { + KeychainHelper.save(key: key, value: value) + } +} + +struct StoreKitProofProvider: StoreKitProofProviding { + func proof() async -> StoreKitProof? { + if #available(macOS 15.0, *), + let result = try? await AppTransaction.shared, + case .verified = result + { + return StoreKitProof(jws: result.jwsRepresentation, type: .appTransaction) + } + for await entitlement in Transaction.currentEntitlements { + guard case .verified(let transaction) = entitlement, + StoreProducts.identifiers.contains(transaction.productID), + transaction.revocationDate == nil + else { continue } + return StoreKitProof(jws: entitlement.jwsRepresentation, type: .transaction) + } + return nil + } +} + +@MainActor +final class ProxySessionManager { + private let clock: () -> Date + private let credentialStore: ProxyCredentialStoring + private let loader: HTTPDataLoading + private let proofProvider: StoreKitProofProviding + private var cachedSession: ProxySession? + private var refreshTask: Task? + private(set) var pendingNonce: String? + + #if DEBUG + var developmentProxyURL = "" + #endif + + init( + proofProvider: StoreKitProofProviding = StoreKitProofProvider(), + credentialStore: ProxyCredentialStoring = KeychainProxyCredentialStore(), + loader: HTTPDataLoading = URLSession.shared, + clock: @escaping () -> Date = Date.init + ) { + self.proofProvider = proofProvider + self.credentialStore = credentialStore + self.loader = loader + self.clock = clock + } + + var sessionSnapshot: ProxySession? { cachedSession } + + static func nonceDigest(_ nonce: String) -> String { + SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() + } + + func prepareChallenge() async throws -> String { + var request = try makeRequest(path: "/session/challenge") + request.httpMethod = "POST" + request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") + let (data, response) = try await load(request) + try requireStatus(response, data: data, expected: 200) + let challenge = try decode(ChallengeResponse.self, from: data) + guard !challenge.nonce.isEmpty else { throw ProxySessionError.invalidResponse } + pendingNonce = challenge.nonce + return challenge.nonce + } + + func signIn(identityToken: String, authorizationCode: String) async throws -> ProxySession { + guard let nonce = pendingNonce else { throw ProxySessionError.challengeRequired } + pendingNonce = nil + let body = SignInRequest( + proof: .init( + authorizationCode: authorizationCode, + identityToken: identityToken, + nonce: nonce + ), + storeKitProof: await proofProvider.proof() + ) + let session = try await sendSessionRequest(path: "/session", body: body) + remember(session) + return session + } + + func session(forceRefresh: Bool = false) async throws -> ProxySession { + if !forceRefresh, + let cachedSession, + cachedSession.expiresAt.timeIntervalSince(clock()) > 15 + { + return cachedSession + } + if let refreshTask { + return try await refreshTask.value + } + let task = Task { @MainActor in + guard let refreshToken = credentialStore.loadRefreshToken(), + !refreshToken.isEmpty + else { + throw ProxySessionError.signInRequired + } + let body = RefreshRequest( + refreshToken: refreshToken, + storeKitProof: await proofProvider.proof() + ) + let session = try await sendSessionRequest(path: "/session/refresh", body: body) + remember(session) + return session + } + refreshTask = task + defer { refreshTask = nil } + return try await task.value + } + + func withSessionRetry( + _ operation: (ProxySession) async throws -> T + ) async throws -> T { + let current = try await session() + do { + return try await operation(current) + } catch GeminiError.invalidSession { + cachedSession = nil + let refreshed = try await session(forceRefresh: true) + return try await operation(refreshed) + } + } + + func logout() async { + defer { clearCredentials() } + guard let refreshToken = credentialStore.loadRefreshToken(), + var request = try? makeRequest(path: "/session/logout") + else { return } + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let cachedSession { + request.setValue( + "Bearer \(cachedSession.sessionToken)", + forHTTPHeaderField: "Authorization" + ) + } + request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") + request.httpBody = try? JSONEncoder().encode(LogoutRequest(refreshToken: refreshToken)) + _ = try? await loader.data(for: request) + } + + func deleteAccount() async throws { + let session = try await session() + var request = try makeRequest(path: "/account") + request.httpMethod = "DELETE" + request.setValue("Bearer \(session.sessionToken)", forHTTPHeaderField: "Authorization") + request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") + let (data, response) = try await load(request) + try requireStatus(response, data: data, expected: 204) + clearCredentials() + } + + func clearCredentials() { + refreshTask?.cancel() + refreshTask = nil + cachedSession = nil + pendingNonce = nil + credentialStore.saveRefreshToken("") + } + + private func remember(_ session: ProxySession) { + cachedSession = session + credentialStore.saveRefreshToken(session.refreshToken) + } + + private func sendSessionRequest(path: String, body: Body) async throws -> ProxySession { + var request = try makeRequest(path: path) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await load(request) + try requireStatus(response, data: data, expected: 200) + return try decode(ProxySession.self, from: data) + } + + private func load(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await loader.data(for: request) + } catch let error as URLError { + throw ProxySessionError.network(error) + } catch let error as ProxySessionError { + throw error + } catch { + throw ProxySessionError.serviceUnavailable + } + } + + private func requireStatus(_ response: URLResponse, data: Data, expected: Int) throws { + guard let http = response as? HTTPURLResponse else { throw ProxySessionError.invalidResponse } + guard http.statusCode == expected else { + let code = Self.errorCode(from: data) + switch (http.statusCode, code) { + case (401, "invalid_refresh"), (401, "apple_credential_revoked"): + clearCredentials() + throw ProxySessionError.signInRequired + case (401, "invalid_challenge"): + throw ProxySessionError.challengeRequired + case (401, _): + throw ProxySessionError.appleVerificationFailed + case (409, "storekit_account_mismatch"): + throw ProxySessionError.storeKitAccountMismatch + case (503, _): + throw ProxySessionError.serviceUnavailable + default: + throw ProxySessionError.invalidResponse + } + } + } + + private func decode(_ type: Value.Type, from data: Data) throws -> Value { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let withoutFraction = ISO8601DateFormatter() + guard let date = withFraction.date(from: value) ?? withoutFraction.date(from: value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid date" + ) + } + return date + } + do { + return try decoder.decode(type, from: data) + } catch { + throw ProxySessionError.invalidResponse + } + } + + private func makeRequest(path: String) throws -> URLRequest { + #if DEBUG + let candidate = developmentProxyURL.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = candidate.isEmpty ? DefaultConfig.proxyURL : candidate + #else + let baseURL = DefaultConfig.proxyURL + #endif + let normalized = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL + guard let url = URL(string: normalized + path), url.scheme == "https" else { + throw ProxySessionError.invalidURL + } + return URLRequest(url: url) + } + + private static func errorCode(from data: Data) -> String? { + struct ErrorEnvelope: Decodable { + struct Inner: Decodable { let code: String? } + let error: Inner? + } + return (try? JSONDecoder().decode(ErrorEnvelope.self, from: data))?.error?.code + } +} + +private struct ChallengeResponse: Decodable { + let nonce: String +} + +private struct SignInRequest: Encodable { + struct Proof: Encodable { + let type = "appleIdentity" + let authorizationCode: String + let identityToken: String + let nonce: String + } + + let proof: Proof + let storeKitProof: StoreKitProof? +} + +private struct RefreshRequest: Encodable { + let refreshToken: String + let storeKitProof: StoreKitProof? +} + +private struct LogoutRequest: Encodable { + let refreshToken: String +} + +enum ProxySessionError: LocalizedError { + case appleVerificationFailed + case challengeRequired + case invalidResponse + case invalidURL + case network(URLError) + case serviceUnavailable + case signInRequired + case storeKitAccountMismatch + + var errorDescription: String? { + switch self { + case .appleVerificationFailed: + return "Apple 로그인을 확인하지 못했습니다. 다시 로그인해주세요." + case .challengeRequired: + return "로그인 준비 시간이 만료되었습니다. 잠시 후 다시 시도해주세요." + case .invalidResponse, .serviceUnavailable: + return "사용 권한을 확인할 수 없습니다. 네트워크 상태를 확인하고 잠시 후 다시 시도해주세요." + case .invalidURL: + return "서버 연결 설정에 문제가 있습니다." + case .network(let error): + switch error.code { + case .notConnectedToInternet, .dataNotAllowed: + return "인터넷에 연결되어 있지 않습니다. 네트워크 연결을 확인해주세요." + case .timedOut: + return "사용 권한 확인 시간이 초과되었습니다. 잠시 후 다시 시도해주세요." + default: + return "사용 권한을 확인하지 못했습니다. 네트워크 상태를 확인하고 다시 시도해주세요." + } + case .signInRequired: + return "Paxo를 사용하려면 Apple로 로그인해주세요." + case .storeKitAccountMismatch: + return "이 App Store 구독은 다른 Paxo 계정에 연결되어 있습니다." + } + } +} diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index d856963..f23a9c2 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -1,4 +1,5 @@ import AppKit +import AuthenticationServices import Combine import ServiceManagement @@ -8,6 +9,7 @@ final class AppState: ObservableObject { enum SolvePhase: Equatable { case idle + case checkingAccess case capturing case solvingAnswer case answerReady @@ -21,6 +23,20 @@ final class AppState: ObservableObject { @Published private(set) var current: SolveResult? @Published private(set) var history: [SolveResult] = [] + enum AuthenticationPhase: Equatable { + case preparing + case signedOut + case signingIn + case signedIn + case failed(String) + } + + @Published private(set) var authenticationPhase: AuthenticationPhase = .preparing + @Published private(set) var isAppleSignInReady = false + @Published private(set) var remainingToday = 0 + @Published private(set) var serverTier: ProxyTier = .free + @Published private(set) var usageResetAt: Date? + // MARK: - 설정 /// 빠른 채점 모드: 정답을 먼저 크게 표시하고, 해설은 "해설 보기"를 눌렀을 때 생성. @@ -88,7 +104,8 @@ final class AppState: ObservableObject { } } private var isSyncingLaunchAtLogin = false - /// 프록시 서버 URL. 직접 호출이 꺼져 있으면 이 값을 우선 사용한다. + #if DEBUG + /// 개발 빌드에서만 프록시 계약을 독립적으로 검증한다. @Published var proxyURL: String { didSet { UserDefaults.standard.set(proxyURL, forKey: "proxyURL") } } @@ -96,22 +113,14 @@ final class AppState: ObservableObject { @Published var apiKey: String { didSet { KeychainHelper.save(key: "gemini-api-key", value: apiKey) } } - #if DEBUG - /// 프록시 장애와 Gemini API 설정을 독립적으로 확인하기 위한 개발용 선택지. @Published var useDirectGemini: Bool { didSet { UserDefaults.standard.set(useDirectGemini, forKey: "useDirectGemini") } } #endif - /// 프록시의 사용량 집계용 익명 기기 식별자 - let deviceID: String - /// 구독 상태 관리 (뷰에는 별도 environmentObject로 주입) let store = StoreManager() - /// 오늘 남은 무료 풀이 횟수 (메뉴 표시용) - @Published private(set) var freeRemaining: Int = UsageTracker.dailyFreeLimit - var canRequestExplanation: Bool { guard let current, current.explanation == nil else { return false } return imageCache[current.id] != nil @@ -124,7 +133,7 @@ final class AppState: ObservableObject { private let paywallWindow = PaywallWindowController() private let onboardingWindow = OnboardingWindowController() private let historyStore = HistoryStore() - private let usage = UsageTracker() + private let sessionManager = ProxySessionManager() /// 세션 내 캡처 이미지 캐시 (SolveResult.id 키). 해설 재생성용. 디스크 저장 안 함. private var imageCache: [UUID: Data] = [:] private var imageCacheOrder: [UUID] = [] @@ -132,6 +141,8 @@ final class AppState: ObservableObject { /// 마지막으로 캡처한 화면 — 결과 패널/토스트를 같은 화면에 띄우기 위함 private var lastCaptureScreen: NSScreen? private var toastDismissTask: Task? + private var credentialRevocationTask: Task? + private static let appleUserKey = "apple-user-identifier" private init() { quickCheckMode = UserDefaults.standard.bool(forKey: "quickCheckMode") @@ -149,30 +160,35 @@ final class AppState: ObservableObject { } else { hotkey = .default } + #if DEBUG proxyURL = UserDefaults.standard.string(forKey: "proxyURL") ?? "" apiKey = KeychainHelper.load(key: "gemini-api-key") ?? "" - #if DEBUG useDirectGemini = UserDefaults.standard.bool(forKey: "useDirectGemini") #endif launchAtLogin = (SMAppService.mainApp.status == .enabled) - - if let existing = UserDefaults.standard.string(forKey: "deviceID") { - deviceID = existing - } else { - let fresh = UUID().uuidString - UserDefaults.standard.set(fresh, forKey: "deviceID") - deviceID = fresh - } } func start() { history = historyStore.load() - freeRemaining = usage.remainingToday() + #if DEBUG + sessionManager.developmentProxyURL = proxyURL + #endif + store.onEntitlementsChanged = { [weak self] in + await self?.refreshServerEntitlements() + } store.start() hotkeyManager.onHotkey = { [weak self] in self?.beginSolve() } hotkeyManager.register(hotkey) + credentialRevocationTask = Task { [weak self] in + for await _ in NotificationCenter.default.notifications( + named: ASAuthorizationAppleIDProvider.credentialRevokedNotification + ) { + await self?.handleCredentialRevocation() + } + } + Task { await restoreAuthentication() } if !UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") { showOnboarding() @@ -189,29 +205,74 @@ final class AppState: ObservableObject { func suspendHotkey() { hotkeyManager.unregister() } func resumeHotkey() { hotkeyManager.register(hotkey) } + func configureAppleSignIn(_ request: ASAuthorizationAppleIDRequest) { + guard let nonce = sessionManager.pendingNonce else { + authenticationPhase = .failed("로그인 준비가 완료되지 않았습니다. 잠시 후 다시 시도해주세요.") + return + } + request.requestedScopes = [] + request.nonce = ProxySessionManager.nonceDigest(nonce) + authenticationPhase = .signingIn + } + + func handleAppleSignIn(_ result: Result) { + Task { await completeAppleSignIn(result) } + } + + func retryAppleSignInPreparation() { + Task { + if KeychainHelper.load(key: Self.appleUserKey)?.isEmpty == false { + await restoreAuthentication() + } else { + await prepareAppleSignIn() + } + } + } + + func signOut() { + Task { + await sessionManager.logout() + KeychainHelper.save(key: Self.appleUserKey, value: "") + resetAuthentication() + await prepareAppleSignIn() + } + } + + func deleteAccount() { + Task { + do { + try await sessionManager.deleteAccount() + KeychainHelper.save(key: Self.appleUserKey, value: "") + resetAuthentication() + await prepareAppleSignIn() + } catch { + authenticationPhase = .failed(errorMessage(from: error)) + } + } + } + func beginSolve() { switch phase { - case .capturing, .solvingAnswer, .solvingExplanation: + case .checkingAccess, .capturing, .solvingAnswer, .solvingExplanation: return default: break } - // 무료 한도 소진 + 미구독이면 캡처 전에 페이월 표시 - if !store.isPro && usage.remainingToday() == 0 { - showPaywall() - return - } - phase = .capturing - Task { await runSolve() } + Task { await prepareAndRunSolve() } } func showPaywall() { paywallWindow.show(store: store) } - /// 메뉴가 열릴 때 남은 무료 횟수를 다시 계산 (자정 넘김 반영) + /// 메뉴가 열릴 때 서버 정본을 새로 읽어 자정 초기화와 구독 변경을 반영한다. func refreshFreeRemaining() { - freeRemaining = usage.remainingToday() + Task { + guard case .signedIn = authenticationPhase else { return } + if let session = try? await sessionManager.session() { + apply(session) + } + } } func showFromHistory(_ item: SolveResult) { @@ -228,6 +289,165 @@ final class AppState: ObservableObject { Task { await runExplanation() } } + private func restoreAuthentication() async { + authenticationPhase = .preparing + guard let userID = KeychainHelper.load(key: Self.appleUserKey), !userID.isEmpty else { + sessionManager.clearCredentials() + await prepareAppleSignIn() + return + } + guard let state = await credentialState(for: userID) else { + authenticationPhase = .failed( + "Apple 로그인 상태를 확인하지 못했습니다. 네트워크 상태를 확인하고 다시 시도해주세요." + ) + return + } + guard state == .authorized else { + sessionManager.clearCredentials() + KeychainHelper.save(key: Self.appleUserKey, value: "") + await prepareAppleSignIn() + return + } + do { + let session = try await sessionManager.session(forceRefresh: true) + apply(session) + authenticationPhase = .signedIn + } catch { + authenticationPhase = .failed(errorMessage(from: error)) + if let sessionError = error as? ProxySessionError, + case .signInRequired = sessionError + { + sessionManager.clearCredentials() + KeychainHelper.save(key: Self.appleUserKey, value: "") + await prepareAppleSignIn(preserveError: true) + } + } + } + + private func prepareAppleSignIn(preserveError: Bool = false) async { + if !preserveError { authenticationPhase = .preparing } + isAppleSignInReady = false + do { + _ = try await sessionManager.prepareChallenge() + isAppleSignInReady = true + if !preserveError { authenticationPhase = .signedOut } + } catch { + authenticationPhase = .failed(errorMessage(from: error)) + } + } + + private func completeAppleSignIn(_ result: Result) async { + do { + let authorization = try result.get() + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let identityData = credential.identityToken, + let identityToken = String(data: identityData, encoding: .utf8), + let codeData = credential.authorizationCode, + let authorizationCode = String(data: codeData, encoding: .utf8) + else { + throw ProxySessionError.appleVerificationFailed + } + let session = try await sessionManager.signIn( + identityToken: identityToken, + authorizationCode: authorizationCode + ) + KeychainHelper.save(key: Self.appleUserKey, value: credential.user) + apply(session) + authenticationPhase = .signedIn + isAppleSignInReady = false + } catch let error as ASAuthorizationError where error.code == .canceled { + authenticationPhase = .signedOut + await prepareAppleSignIn() + } catch { + authenticationPhase = .failed(errorMessage(from: error)) + await prepareAppleSignIn(preserveError: true) + } + } + + private func credentialState( + for userID: String + ) async -> ASAuthorizationAppleIDProvider.CredentialState? { + await withCheckedContinuation { continuation in + ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, error in + continuation.resume(returning: error == nil ? state : nil) + } + } + } + + private func handleCredentialRevocation() async { + await sessionManager.logout() + KeychainHelper.save(key: Self.appleUserKey, value: "") + resetAuthentication() + await prepareAppleSignIn() + } + + private func refreshServerEntitlements() async { + guard case .signedIn = authenticationPhase else { return } + do { + let session = try await sessionManager.session(forceRefresh: true) + apply(session) + } catch { + authenticationPhase = .failed(errorMessage(from: error)) + } + } + + private func resetAuthentication() { + authenticationPhase = .signedOut + remainingToday = 0 + serverTier = .free + usageResetAt = nil + } + + private func apply(_ session: ProxySession) { + remainingToday = session.remainingToday + serverTier = session.tier + usageResetAt = session.resetAt + } + + private func apply(_ result: GenerationResult) { + if let remaining = result.remainingToday { remainingToday = remaining } + if let resetAt = result.resetAt { usageResetAt = resetAt } + if let tier = result.tier { serverTier = tier } + } + + private func prepareAndRunSolve() async { + #if DEBUG + if useDirectGemini { + phase = .capturing + await runSolve() + return + } + #endif + guard case .signedIn = authenticationPhase else { + authenticationPhase = .failed("Paxo를 사용하려면 Apple로 로그인해주세요.") + await prepareAppleSignIn(preserveError: true) + return + } + phase = .checkingAccess + do { + let session = try await sessionManager.session() + apply(session) + guard session.remainingToday > 0 else { + phase = .idle + if session.tier == .free { + showPaywall() + } else { + phase = .failedAnswer("Pro의 일일 안전 한도 100회를 모두 사용했습니다. 내일 다시 이용해주세요.") + resultPanel.show(appState: self, on: Self.screenUnderMouse()) + } + return + } + phase = .capturing + await runSolve() + } catch { + phase = .idle + authenticationPhase = .failed(errorMessage(from: error)) + if case ProxySessionError.signInRequired = error { + await prepareAppleSignIn(preserveError: true) + } + } + } + private func runSolve() async { toastDismissTask?.cancel() do { @@ -242,24 +462,32 @@ final class AppState: ObservableObject { phase = .solvingAnswer presentSolving() - let answer = try await makeService() - .answer(imageData: capture.data, preset: preset) - current?.answer = answer + let generated = try await requestAnswer( + imageData: capture.data, + preset: preset, + solveID: result.id + ) + apply(generated) + current?.answer = generated.text phase = .answerReady upsertHistory() - // 해설은 풀이 1회에 포함 — 정답 호출 성공 시에만 무료 사용량 차감 - if !store.isPro { - usage.recordUse() - freeRemaining = usage.remainingToday() - } - if resultDisplayMode == .toast { toast.show(appState: self, on: lastCaptureScreen) scheduleToastDismiss() } else if !quickCheckMode { await runExplanation() } + } catch GeminiError.freeDailyLimit { + toast.dismiss() + remainingToday = 0 + phase = .idle + showPaywall() + } catch GeminiError.proDailyLimit { + toast.dismiss() + remainingToday = 0 + phase = .failedAnswer("Pro의 일일 안전 한도 100회를 모두 사용했습니다. 내일 다시 이용해주세요.") + resultPanel.show(appState: self, on: lastCaptureScreen) } catch { toast.dismiss() phase = .failedAnswer(errorMessage(from: error)) @@ -297,9 +525,14 @@ final class AppState: ObservableObject { else { return } phase = .solvingExplanation do { - let explanation = try await makeService() - .explain(imageData: image, answer: answer, preset: preset) - self.current?.explanation = explanation + let generated = try await requestExplanation( + imageData: image, + answer: answer, + preset: preset, + solveID: current.id + ) + apply(generated) + self.current?.explanation = generated.text phase = .done upsertHistory() } catch { @@ -326,12 +559,66 @@ final class AppState: ObservableObject { GeminiService( apiKey: apiKey, proxyURL: proxyURL, - deviceID: deviceID, useDirectGemini: useDirectGemini ) #else - GeminiService(apiKey: apiKey, proxyURL: proxyURL, deviceID: deviceID, useDirectGemini: false) + GeminiService(apiKey: "", proxyURL: DefaultConfig.proxyURL, useDirectGemini: false) + #endif + } + + private func requestAnswer( + imageData: Data, + preset: SubjectPreset, + solveID: UUID + ) async throws -> GenerationResult { + let service = makeService() + #if DEBUG + if useDirectGemini { + return try await service.answer( + imageData: imageData, + preset: preset, + sessionToken: "development-direct-call", + solveID: solveID + ) + } #endif + return try await sessionManager.withSessionRetry { session in + try await service.answer( + imageData: imageData, + preset: preset, + sessionToken: session.sessionToken, + solveID: solveID + ) + } + } + + private func requestExplanation( + imageData: Data, + answer: String, + preset: SubjectPreset, + solveID: UUID + ) async throws -> GenerationResult { + let service = makeService() + #if DEBUG + if useDirectGemini { + return try await service.explain( + imageData: imageData, + answer: answer, + preset: preset, + sessionToken: "development-direct-call", + solveID: solveID + ) + } + #endif + return try await sessionManager.withSessionRetry { session in + try await service.explain( + imageData: imageData, + answer: answer, + preset: preset, + sessionToken: session.sessionToken, + solveID: solveID + ) + } } private func upsertHistory() { diff --git a/Paxo/DefaultConfig.swift b/Paxo/DefaultConfig.swift index c2220c5..5ca05c7 100644 --- a/Paxo/DefaultConfig.swift +++ b/Paxo/DefaultConfig.swift @@ -3,8 +3,7 @@ import Foundation /// 출시 빌드에 내장되는 기본 설정. /// 심사원과 일반 사용자는 설정을 건드리지 않아도 이 프록시로 바로 동작한다. enum DefaultConfig { - /// 기본 프록시 URL (설정에서 사용자가 입력하면 그 값이 우선) - /// Vercel(미국 리전 고정) — Cloudflare Workers는 Gemini 지역 차단이 간헐 발생해 백업으로만 유지 + /// Release 빌드는 이 주소만 사용하며 사용자 설정으로 바꿀 수 없다. static let proxyURL = "https://api.paxo.co.kr" /// 개인정보 처리방침 (프록시 워커가 /privacy 경로로 서빙) @@ -13,9 +12,6 @@ enum DefaultConfig { /// 이용약관 — Apple 표준 EULA static let termsOfUseURL = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" - /// 프록시 접근 토큰 (x-paxo-token 헤더). 값은 Git에 올라가지 않는 `Paxo/Secrets.swift`에 있다 - /// (클론했다면 `Config/Secrets.example.swift`를 복사할 것 — README '개발 셋업' 참고). - /// 바이너리에서 추출 가능한 공유 시크릿이므로 완전한 방어가 아니라 - /// 드라이브바이 어뷰징 차단용이다. 정식 방어는 향후 영수증 검증. + /// 바이너리에서 추출 가능한 앱 버전 필터이며 실제 권한은 Apple 로그인 세션으로 검증한다. static let appToken: String = Secrets.appToken } diff --git a/Paxo/Storage/KeychainHelper.swift b/Paxo/Storage/KeychainHelper.swift index dae05d2..abb9236 100644 --- a/Paxo/Storage/KeychainHelper.swift +++ b/Paxo/Storage/KeychainHelper.swift @@ -22,6 +22,7 @@ enum KeychainHelper { ) } else { var addQuery = query + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly addQuery[kSecValueData as String] = data SecItemAdd(addQuery as CFDictionary, nil) } diff --git a/Paxo/Store/StoreManager.swift b/Paxo/Store/StoreManager.swift index e6d1525..fd02f62 100644 --- a/Paxo/Store/StoreManager.swift +++ b/Paxo/Store/StoreManager.swift @@ -2,22 +2,26 @@ import Combine import Foundation import StoreKit +enum StoreProducts { + static let identifiers = [ + "com.hyeseong.Paxo.pro.monthly", + "com.hyeseong.Paxo.pro.yearly", + ] +} + /// StoreKit 2 구독 관리. /// - 로컬 테스트: Config/Paxo.storekit (스킴 → Run → Options → StoreKit Configuration에서 선택) /// - 출시 전: App Store Connect에 동일한 product ID로 구독 상품 생성 필요 @MainActor final class StoreManager: ObservableObject { - static let productIDs = [ - "com.hyeseong.Paxo.pro.monthly", - "com.hyeseong.Paxo.pro.yearly", - ] - @Published private(set) var products: [Product] = [] @Published private(set) var isPro = false @Published private(set) var purchaseInFlight = false @Published var errorMessage: String? + var onEntitlementsChanged: (() async -> Void)? private var updatesTask: Task? + private var hasLoadedEntitlements = false func start() { // 앱 실행 중 들어오는 트랜잭션(갱신, 환불, 다른 기기 구매 등) 반영 @@ -37,7 +41,7 @@ final class StoreManager: ObservableObject { func loadProducts() async { do { - products = try await Product.products(for: Self.productIDs) + products = try await Product.products(for: StoreProducts.identifiers) .sorted { $0.price < $1.price } } catch { products = [] @@ -46,16 +50,21 @@ final class StoreManager: ObservableObject { } func refreshEntitlements() async { + let previous = isPro var pro = false for await entitlement in Transaction.currentEntitlements { if case .verified(let transaction) = entitlement, - Self.productIDs.contains(transaction.productID), + StoreProducts.identifiers.contains(transaction.productID), transaction.revocationDate == nil { pro = true } } isPro = pro + if hasLoadedEntitlements, previous != pro { + await onEntitlementsChanged?() + } + hasLoadedEntitlements = true } func purchase(_ product: Product) async { diff --git a/Paxo/Store/UsagePolicy.swift b/Paxo/Store/UsagePolicy.swift new file mode 100644 index 0000000..ba9d1b2 --- /dev/null +++ b/Paxo/Store/UsagePolicy.swift @@ -0,0 +1,4 @@ +enum UsagePolicy { + static let dailyFreeLimit = 3 + static let dailyProLimit = 100 +} diff --git a/Paxo/Store/UsageTracker.swift b/Paxo/Store/UsageTracker.swift deleted file mode 100644 index 8562694..0000000 --- a/Paxo/Store/UsageTracker.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -/// 무료 사용량 (하루 N회) 추적. 날짜가 바뀌면 리셋. -/// -/// v1은 클라이언트 카운트 + 프록시의 기기별 일일 한도(KV)를 백스톱으로 쓴다. -/// 정식 서버 검증(StoreKit 영수증 JWS를 프록시에서 확인)은 출시 전 TODO. -struct UsageTracker { - static let dailyFreeLimit = 3 - - private let countKey = "usage.count" - private let dayKey = "usage.day" - - /// 테스트에서 격리된 저장소를 주입하기 위한 지점. 앱에서는 항상 기본값을 쓴다. - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - private var today: String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: Date()) - } - - func usedToday() -> Int { - guard defaults.string(forKey: dayKey) == today else { return 0 } - return defaults.integer(forKey: countKey) - } - - func remainingToday() -> Int { - max(0, Self.dailyFreeLimit - usedToday()) - } - - func recordUse() { - if defaults.string(forKey: dayKey) != today { - defaults.set(today, forKey: dayKey) - defaults.set(0, forKey: countKey) - } - defaults.set(defaults.integer(forKey: countKey) + 1, forKey: countKey) - } -} diff --git a/Paxo/UI/MenuContentView.swift b/Paxo/UI/MenuContentView.swift index 187cfd7..eb177bf 100644 --- a/Paxo/UI/MenuContentView.swift +++ b/Paxo/UI/MenuContentView.swift @@ -1,8 +1,8 @@ +import AuthenticationServices import SwiftUI struct MenuContentView: View { @EnvironmentObject private var appState: AppState - @EnvironmentObject private var store: StoreManager @Environment(\.dismiss) private var dismiss var body: some View { @@ -14,32 +14,70 @@ struct MenuContentView: View { } .onAppear { appState.refreshFreeRemaining() } - Button { - dismiss() - appState.beginSolve() - } label: { - Label("화면 캡처로 풀기", systemImage: "camera.viewfinder") - .frame(maxWidth: .infinity) - } - .controlSize(.large) + if appState.authenticationPhase == .signedIn { + Button { + dismiss() + appState.beginSolve() + } label: { + Label("화면 캡처로 풀기", systemImage: "camera.viewfinder") + .frame(maxWidth: .infinity) + } + .controlSize(.large) - Text("전역 단축키 \(appState.hotkey.display)") - .font(.caption) - .foregroundStyle(.secondary) + Text("전역 단축키 \(appState.hotkey.display)") + .font(.caption) + .foregroundStyle(.secondary) - if store.isPro { - Label("Paxo Pro 사용 중", systemImage: "checkmark.seal.fill") + if appState.serverTier == .pro { + Label( + "Paxo Pro · 오늘 \(appState.remainingToday)회 남음", + systemImage: "checkmark.seal.fill" + ) .font(.caption) .foregroundStyle(.secondary) + } else { + HStack { + Text("오늘 무료 풀이 \(appState.remainingToday)회 남음") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Pro 알아보기") { + dismiss() + appState.showPaywall() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.tint) + } + } } else { - HStack { - Text("오늘 무료 풀이 \(appState.freeRemaining)회 남음") + if appState.authenticationPhase == .preparing + || appState.authenticationPhase == .signingIn + { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Apple 로그인을 준비하는 중…") + } + .font(.caption) + .foregroundStyle(.secondary) + } + + SignInWithAppleButton( + .continue, + onRequest: appState.configureAppleSignIn, + onCompletion: appState.handleAppleSignIn + ) + .signInWithAppleButtonStyle(.black) + .frame(height: 36) + .disabled(!appState.isAppleSignInReady) + + if case .failed(let message) = appState.authenticationPhase { + Text(message) .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Pro 알아보기") { - dismiss() - appState.showPaywall() + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + Button("다시 준비") { + appState.retryAppleSignInPreparation() } .buttonStyle(.plain) .font(.caption) @@ -88,6 +126,6 @@ struct MenuContentView: View { .font(.callout) } .padding(12) - .frame(width: 260) + .frame(width: 280) } } diff --git a/Paxo/UI/PaywallView.swift b/Paxo/UI/PaywallView.swift index 907b427..f2bd8eb 100644 --- a/Paxo/UI/PaywallView.swift +++ b/Paxo/UI/PaywallView.swift @@ -14,14 +14,19 @@ struct PaywallView: View { .font(.title2.bold()) if store.isPro { - Label("Pro 사용 중 — 무제한으로 풀이와 해설을 볼 수 있어요.", systemImage: "checkmark.seal.fill") - .font(.callout) - .multilineTextAlignment(.center) + Label( + "Pro 사용 중 — 하루 최대 \(UsagePolicy.dailyProLimit)회 풀이할 수 있어요.", + systemImage: "checkmark.seal.fill" + ) + .font(.callout) + .multilineTextAlignment(.center) } else { - Text("무료 풀이는 하루 \(UsageTracker.dailyFreeLimit)회예요.\nPro로 업그레이드하면 풀이와 해설을 무제한으로 볼 수 있어요.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + Text( + "무료 풀이는 하루 \(UsagePolicy.dailyFreeLimit)회예요.\nPro는 비용 보호를 위해 하루 최대 \(UsagePolicy.dailyProLimit)회 제공돼요." + ) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) if store.products.isEmpty { HStack(spacing: 8) { diff --git a/Paxo/UI/ResultView.swift b/Paxo/UI/ResultView.swift index 2a19bec..e84785e 100644 --- a/Paxo/UI/ResultView.swift +++ b/Paxo/UI/ResultView.swift @@ -19,6 +19,8 @@ struct ResultView: View { switch appState.phase { case .idle: placeholder("\(appState.hotkey.display) 를 눌러 화면 속 문제를 풀어보세요.") + case .checkingAccess: + loading("사용 가능 횟수를 확인하는 중…") case .capturing: placeholder( appState.captureMode == .fullScreen diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 48c91aa..2511088 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -6,6 +6,34 @@ struct SettingsView: View { var body: some View { Form { + Section("계정") { + switch appState.authenticationPhase { + case .signedIn: + Label("Apple로 로그인됨", systemImage: "person.crop.circle.badge.checkmark") + Button("로그아웃") { + appState.signOut() + } + Button("계정 삭제…", role: .destructive) { + showsDeleteConfirmation = true + } + case .failed(let message): + Text(message) + .font(.caption) + .foregroundStyle(.red) + Button("로그인 다시 준비") { + appState.retryAppleSignInPreparation() + } + case .preparing, .signingIn: + ProgressView("Apple 로그인을 확인하는 중…") + case .signedOut: + Text("메뉴 막대의 Paxo 메뉴에서 Apple로 로그인해주세요.") + .foregroundStyle(.secondary) + } + Text("이름과 이메일은 요청하거나 저장하지 않습니다.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("결과 표시") { Picker("표시 방식", selection: $appState.resultDisplayMode) { ForEach(ResultDisplayMode.allCases) { mode in @@ -89,10 +117,16 @@ struct SettingsView: View { #endif Section("구독") { - if store.isPro { - Label("Paxo Pro 사용 중", systemImage: "checkmark.seal.fill") + if appState.serverTier == .pro { + Label( + "Paxo Pro · 오늘 \(appState.remainingToday)회 남음", + systemImage: "checkmark.seal.fill" + ) } else { - LabeledContent("무료 사용량", value: "하루 \(UsageTracker.dailyFreeLimit)회") + LabeledContent( + "무료 사용량", + value: "오늘 \(appState.remainingToday)/\(UsagePolicy.dailyFreeLimit)회 남음" + ) Button("Paxo Pro 알아보기…") { appState.showPaywall() } @@ -129,5 +163,15 @@ struct SettingsView: View { } .formStyle(.grouped) .frame(width: 460, height: 660) + .alert("Paxo 계정을 삭제할까요?", isPresented: $showsDeleteConfirmation) { + Button("취소", role: .cancel) {} + Button("계정 삭제", role: .destructive) { + appState.deleteAccount() + } + } message: { + Text("Apple 로그인 연결과 서버의 사용량 정보가 삭제되며 되돌릴 수 없습니다. 기기의 풀이 기록은 유지됩니다.") + } } + + @State private var showsDeleteConfirmation = false } diff --git a/Paxo/UI/ToastController.swift b/Paxo/UI/ToastController.swift index 052f117..7c5d78d 100644 --- a/Paxo/UI/ToastController.swift +++ b/Paxo/UI/ToastController.swift @@ -94,7 +94,7 @@ private struct ToastView: View { @ViewBuilder private var content: some View { switch appState.phase { - case .capturing, .solvingAnswer: + case .checkingAccess, .capturing, .solvingAnswer: HStack(spacing: 10) { ProgressView().controlSize(.small) Text("푸는 중…") diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift index a83d3f1..14570ab 100644 --- a/PaxoTests/GeminiServiceTests.swift +++ b/PaxoTests/GeminiServiceTests.swift @@ -4,34 +4,33 @@ import Testing @testable import Paxo struct GeminiServiceTests { - private let deviceID = "123E4567-E89B-12D3-A456-426614174000" - - @Test func 기본_요청은_프록시와_인증_헤더를_사용한다() throws { + @Test("프록시 요청은 앱 토큰과 Bearer 세션을 사용한다") + func proxyRequestUsesSessionHeaders() throws { let service = GeminiService( apiKey: "development-key", proxyURL: "https://proxy.example.com/", - deviceID: deviceID, useDirectGemini: false ) - let request = try service.makeRequest() + let request = try service.makeRequest(sessionToken: "session-token") #expect(request.url?.absoluteString == "https://proxy.example.com/generate") - #expect(request.value(forHTTPHeaderField: "x-paxo-device") == deviceID) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer session-token") #expect(request.value(forHTTPHeaderField: "x-paxo-token") != nil) #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == nil) + #expect(request.value(forHTTPHeaderField: "x-paxo-device") == nil) } #if DEBUG - @Test func 직접_호출은_프록시를_우회하고_Gemini_키를_사용한다() throws { + @Test("개발용 직접 호출은 프록시를 우회한다") + func directRequestUsesGeminiKey() throws { let service = GeminiService( apiKey: "development-key", proxyURL: "https://proxy.example.com", - deviceID: deviceID, useDirectGemini: true ) - let request = try service.makeRequest() + let request = try service.makeRequest(sessionToken: "unused") #expect(request.url?.host == "generativelanguage.googleapis.com") #expect(request.url?.path.contains("gemini-3.6-flash:generateContent") == true) @@ -39,16 +38,16 @@ struct GeminiServiceTests { #expect(request.value(forHTTPHeaderField: "x-paxo-token") == nil) } - @Test func 직접_호출은_키가_없으면_설정_오류를_반환한다() { + @Test("개발용 직접 호출은 키가 없으면 실패한다") + func directRequestRequiresKey() { let service = GeminiService( apiKey: "", proxyURL: "https://proxy.example.com", - deviceID: deviceID, useDirectGemini: true ) #expect(throws: GeminiError.self) { - _ = try service.makeRequest() + _ = try service.makeRequest(sessionToken: "unused") } } #endif diff --git a/PaxoTests/ProxySessionManagerTests.swift b/PaxoTests/ProxySessionManagerTests.swift new file mode 100644 index 0000000..107a835 --- /dev/null +++ b/PaxoTests/ProxySessionManagerTests.swift @@ -0,0 +1,185 @@ +import Foundation +import Testing + +@testable import Paxo + +@MainActor +struct ProxySessionManagerTests { + @Test("Apple 로그인 증명을 서버 세션으로 교환한다") + func exchangesAppleIdentityForSession() async throws { + let credentials = StubCredentialStore() + let loader = StubLoader { request in + if request.url?.path == "/session/challenge" { + return try response(request: request, status: 200, body: #"{"nonce":"raw-nonce"}"#) + } + #expect(request.url?.absoluteString == "https://api.paxo.co.kr/session") + #expect(request.value(forHTTPHeaderField: "x-paxo-token") != nil) + let requestBody = try #require(request.httpBody) + let decoded = try JSONSerialization.jsonObject(with: requestBody) + let object = try #require(decoded as? [String: Any]) + let proof = try #require(object["proof"] as? [String: Any]) + #expect(proof["type"] as? String == "appleIdentity") + #expect(proof["identityToken"] as? String == "identity-token") + #expect(proof["authorizationCode"] as? String == "authorization-code") + #expect(proof["nonce"] as? String == "raw-nonce") + let storeKitProof = try #require(object["storeKitProof"] as? [String: Any]) + #expect(storeKitProof["type"] as? String == "appTransaction") + #expect(storeKitProof["jws"] as? String == "header.payload.signature") + return try sessionResponse(request: request) + } + let manager = ProxySessionManager( + proofProvider: StubProof( + value: StoreKitProof(jws: "header.payload.signature", type: .appTransaction) + ), + credentialStore: credentials, + loader: loader + ) + + _ = try await manager.prepareChallenge() + let session = try await manager.signIn( + identityToken: "identity-token", + authorizationCode: "authorization-code" + ) + + #expect(session.sessionToken == "opaque-token") + #expect(session.remainingToday == 2) + #expect(credentials.value == "refresh-token") + #expect(loader.callCount == 2) + } + + @Test("저장된 갱신 토큰으로 세션을 복구하고 메모리에서 재사용한다") + func restoresAndCachesSession() async throws { + let credentials = StubCredentialStore(value: "old-refresh-token") + let loader = StubLoader { request in + #expect(request.url?.path == "/session/refresh") + return try sessionResponse(request: request) + } + let manager = ProxySessionManager( + proofProvider: StubProof(value: nil), + credentialStore: credentials, + loader: loader, + clock: { Date(timeIntervalSince1970: 1_758_510_000) } + ) + + _ = try await manager.session() + _ = try await manager.session() + + #expect(loader.callCount == 1) + #expect(credentials.value == "refresh-token") + } + + @Test("갱신 토큰이 없으면 네트워크 요청 전에 로그인을 요구한다") + func requiresSignInBeforeNetworkRequest() async { + let loader = StubLoader { request in + try sessionResponse(request: request) + } + let manager = ProxySessionManager( + proofProvider: StubProof(value: nil), + credentialStore: StubCredentialStore(), + loader: loader + ) + + await #expect(throws: ProxySessionError.self) { + _ = try await manager.session() + } + #expect(loader.callCount == 0) + } + + @Test("동시에 요청한 세션 갱신은 한 번의 네트워크 호출을 공유한다") + func coalescesConcurrentRefreshes() async throws { + let loader = StubLoader { request in + try sessionResponse(request: request) + } + let manager = ProxySessionManager( + proofProvider: StubProof(value: nil), + credentialStore: StubCredentialStore(value: "old-refresh-token"), + loader: loader + ) + + async let first = manager.session(forceRefresh: true) + async let second = manager.session(forceRefresh: true) + _ = try await (first, second) + + #expect(loader.callCount == 1) + } + + @Test("401은 세션을 한 번 갱신한 뒤 요청을 한 번만 재시도한다") + func retriesOnceAfterExpiredSession() async throws { + let loader = StubLoader { request in + try sessionResponse(request: request) + } + let manager = ProxySessionManager( + proofProvider: StubProof(value: nil), + credentialStore: StubCredentialStore(value: "old-refresh-token"), + loader: loader, + clock: { Date(timeIntervalSince1970: 1_758_510_000) } + ) + var operationCount = 0 + + let token = try await manager.withSessionRetry { session in + operationCount += 1 + if operationCount == 1 { throw GeminiError.invalidSession } + return session.sessionToken + } + + #expect(operationCount == 2) + #expect(loader.callCount == 2) + #expect(token == "opaque-token") + } +} + +private struct StubProof: StoreKitProofProviding { + let value: StoreKitProof? + + func proof() async -> StoreKitProof? { value } +} + +private final class StubCredentialStore: ProxyCredentialStoring { + var value: String? + + init(value: String? = nil) { + self.value = value + } + + func loadRefreshToken() -> String? { value } + func saveRefreshToken(_ value: String) { self.value = value.isEmpty ? nil : value } +} + +private final class StubLoader: HTTPDataLoading { + private let handler: (URLRequest) throws -> (Data, URLResponse) + private(set) var callCount = 0 + + init(handler: @escaping (URLRequest) throws -> (Data, URLResponse)) { + self.handler = handler + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + callCount += 1 + return try handler(request) + } +} + +private func sessionResponse(request: URLRequest) throws -> (Data, URLResponse) { + try response( + request: request, + status: 200, + body: """ + { + "expiresAt":"2030-01-01T00:00:00.000Z", + "refreshToken":"refresh-token", + "remainingToday":2, + "resetAt":"2030-01-01T15:00:00.000Z", + "sessionToken":"opaque-token", + "tier":"free" + } + """ + ) +} + +private func response(request: URLRequest, status: Int, body: String) throws -> (Data, URLResponse) { + let url = try #require(request.url) + let value = try #require( + HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil) + ) + return (Data(body.utf8), value) +} diff --git a/PaxoTests/UsageTrackerTests.swift b/PaxoTests/UsageTrackerTests.swift deleted file mode 100644 index 82d231b..0000000 --- a/PaxoTests/UsageTrackerTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import Testing - -@testable import Paxo - -/// 무료 사용량 차감은 매출에 직결된다. 날짜 롤오버가 깨지면 무제한으로 쓰이거나 -/// 반대로 결제한 적 없는 사용자가 조기에 막힌다. -struct UsageTrackerTests { - /// 매 테스트마다 격리된 UserDefaults. suite 이름이 겹치면 서로 간섭한다. - private func makeDefaults(_ name: String) throws -> UserDefaults { - let defaults = try #require(UserDefaults(suiteName: "PaxoTests.\(name).\(UUID().uuidString)")) - defaults.removePersistentDomain(forName: defaults.description) - return defaults - } - - private var today: String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: Date()) - } - - @Test func 초기_상태는_한도_전체가_남는다() throws { - let tracker = UsageTracker(defaults: try makeDefaults("fresh")) - #expect(tracker.usedToday() == 0) - #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit) - } - - @Test func 사용할수록_남은_횟수가_줄어든다() throws { - let tracker = UsageTracker(defaults: try makeDefaults("decrement")) - for used in 1...UsageTracker.dailyFreeLimit { - tracker.recordUse() - #expect(tracker.usedToday() == used) - #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit - used) - } - } - - /// 한도를 넘겨도 remainingToday가 음수가 되면 안 된다. - @Test func 한도를_넘겨도_남은_횟수는_0_아래로_안_간다() throws { - let tracker = UsageTracker(defaults: try makeDefaults("overflow")) - for _ in 0..<(UsageTracker.dailyFreeLimit + 5) { - tracker.recordUse() - } - #expect(tracker.remainingToday() == 0) - } - - /// 저장된 날짜가 오늘이 아니면 사용량은 0으로 보여야 한다. - @Test func 날짜가_바뀌면_사용량이_리셋된다() throws { - let defaults = try makeDefaults("rollover") - defaults.set("2020-01-01", forKey: "usage.day") - defaults.set(UsageTracker.dailyFreeLimit, forKey: "usage.count") - - let tracker = UsageTracker(defaults: defaults) - #expect(tracker.usedToday() == 0) - #expect(tracker.remainingToday() == UsageTracker.dailyFreeLimit) - } - - /// 롤오버 후 첫 사용은 1회여야 한다. 이전 카운트가 이어지면 안 된다. - @Test func 롤오버_후_첫_사용은_1회로_기록된다() throws { - let defaults = try makeDefaults("rollover-record") - defaults.set("2020-01-01", forKey: "usage.day") - defaults.set(99, forKey: "usage.count") - - let tracker = UsageTracker(defaults: defaults) - tracker.recordUse() - #expect(tracker.usedToday() == 1) - #expect(defaults.string(forKey: "usage.day") == today) - } - - @Test func 무료_한도는_하루_3회다() { - #expect(UsageTracker.dailyFreeLimit == 3) - } -} diff --git a/README.md b/README.md index ba1c754..0ad359d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 1. **캡처 (Capture)**: 메뉴바 아이콘 클릭 또는 전역 단축키 `⌥⌘S` (설정 변경 가능) 2. **영역 선택 (Selection)**: 문제 영역을 드래그하여 지정 (`Esc` 취소). 설정에서 **전체 화면** 모드 활성화 시 드래그 없이 즉시 캡처. -3. **결과 노출 (Result)**: 정답이 먼저 화면에 표시되고, 이어서 해설이 스트리밍된다. +3. **결과 노출 (Result)**: 정답이 먼저 화면에 표시되고, 이어서 해설이 생성된다. 4. **빠른 채점 모드 (Fast Grading)**: 설정에서 켤 수 있으며, 정답만 크게 강조 표시하고 해설은 "해설 보기" 클릭 시 Lazy-load 방식으로 생성한다 (문제집 셀프 채점용). 5. **토스트 모드 (Toast Mode)**: 정답만 토스트 팝업으로 2~10초간 노출된 후 사라지며 해설은 아예 생성하지 않는다. 6. **결과 패널 위치**: 화면 내 7가지 프리셋 중 원하는 위치를 지정할 수 있다. @@ -18,7 +18,7 @@ ```text Paxo/ PaxoApp.swift 앱 진입점 (MenuBarExtra, Settings) - AppState.swift 상태 머신 (캡처 → 정답 → 해설, 히스토리 관리) + AppState.swift Apple 로그인, 캡처 → 정답 → 해설 상태와 히스토리 관리 HotkeyManager.swift 전역 단축키 `⌥⌘S` 처리 (Carbon, MAS 허용 API) DefaultConfig.swift 내장 기본 설정 (프록시 URL 등) Secrets.swift 로컬 시크릿 키 관리 (Git 제외됨) @@ -27,14 +27,14 @@ Paxo/ ScreenCapturer.swift ScreenCaptureKit 연동 캡처 로직 (App Sandbox 지원) AI/ GeminiService.swift AI API 연동 (정답 1차 호출 → 해설 2차 호출) + ProxySessionManager.swift Apple 로그인 세션 발급·복구 및 서버 권한 관리 Prompts.swift 시스템 프롬프트 관리 UI/ 메뉴바 팝업 / 패널 / 토스트 / 페이월 / 마크다운 렌더러 - Store/ StoreKit 2 연동 (구독 및 무료 횟수 관리) + Store/ StoreKit 2 구매 UI와 사용량 정책 상수 Storage/ 로컬 히스토리 JSON 및 키체인 관리 PaxoTests/ 순수 비즈니스 로직 테스트 (Swift Testing 프레임워크) Config/ Info.plist, App Sandbox Entitlements, StoreKit 설정, 시크릿 템플릿 -proxy-vercel/ Vercel 기반 주 프록시 (Gemini API 키 보호, 토큰 검증, 사용량 제어) -proxy/ [Legacy] Cloudflare Worker 기반 백업 프록시 +proxy-vercel/ Apple 로그인·구독 검증·Redis 사용량 제한·Gemini 프록시 docs/ 아키텍처, 도메인 용어, 의사결정 기록 등 문서화 .claude/skills/ Claude Code 전용 커스텀 스킬 .github/workflows/ CI 파이프라인 설정 (빌드, 테스트, 린트, 금지 파일 검사) diff --git a/docs/architecture.md b/docs/architecture.md index 2cac371..6bf2415 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,32 +8,36 @@ | API 프록시 | `proxy-vercel/` | 서버에 Gemini API 키를 보관하고 클라이언트 대신 요청을 수행한다. (Vercel iad1 리전) | | Gemini API | 외부 | 정답 및 해설 생성. 사용할 모델은 프록시 서버에서 결정한다. | | StoreKit 2 · App Store | 외부 | Pro 요금제(월간/연간) 결제 처리 및 구독 상태 검증. | -| 레거시 프록시 | `proxy/` | Cloudflare Worker 기반의 백업용 프록시. 신규 개발은 진행하지 않는다. | +| Apple 로그인 | 외부 | 사용자 인증. 이름·이메일 권한은 요청하지 않는다. | +| Upstash Redis | 외부 | 세션, 일일 사용량, 중복 요청 예약을 TTL과 함께 저장한다. | ## 단일 풀이 흐름 (Solve Flow) ``` 단축키 ⌥⌘S 입력 - → AppState.beginSolve 무료 제공량 및 Pro 구독 여부 확인 (한도 초과 시 캡처 전 결제 화면 노출) + → ProxySessionManager Apple 로그인 세션 및 서버 잔여량 확인 + → AppState.beginSolve 한도 초과 시 캡처 전 결제 화면 또는 안전 한도 안내 노출 → SelectionOverlay 캡처 영역 선택 (전체 화면 모드일 경우 생략) → ScreenCapturer ScreenCaptureKit 사용 (JPEG q0.82, 최대 2000px) → GeminiService POST {프록시}/generate — 프록시 서버에 정답 요청 - → proxy-vercel/api/generate 요청 헤더 검증 → 본문을 허용된 스펙으로 재조립 후 Gemini 호출 + → proxy-vercel/api/generate Bearer 세션과 Redis 한도 검증 → 허용된 본문만 재조립 후 Gemini 호출 → 결과 패널 및 토스트 UI에 정답 표시 → (필요 시) 해설 요청 동일한 경로로 해설 추가 요청 ``` - 정답과 해설은 각각 별도의 API 요청으로 처리한다. -- 무료 횟수는 '정답 요청'이 성공했을 때만 차감한다. 해설 요청 실패 시에는 추가로 차감하지 않는다. +- 무료 3회와 Pro 100회 한도는 Asia/Seoul 날짜 기준으로 서버에서 원자적으로 적용한다. +- 횟수는 '정답 요청'이 성공했을 때만 차감한다. 동일 풀이의 해설 1회는 추가 차감하지 않는다. - 토스트 모드에서는 해설을 요청하지 않는다. ## 앱 ↔ 프록시 API 스펙 (Contract) 핵심 요약만 기재하며, 상세 스펙은 proxy-vercel/AGENTS.md를 정본으로 삼는다. -- 엔드포인트: POST /generate -- 필수 헤더: x-paxo-device (UUID), x-paxo-token -- 본문(Body): 텍스트 파트 1개, 이미지 파트 1개만 허용한다. 프록시 서버에서 허용된 필드만 추출해 재조립하므로, 이외의 필드는 전송해도 무시된다. +- 인증 엔드포인트: `POST /session/challenge`, `POST /session`, `POST /session/refresh` +- 생성 엔드포인트: `POST /generate` +- 생성 필수 헤더: `Authorization: Bearer `, `x-paxo-token` +- 생성 본문: `kind`, `solveId`, 텍스트 파트 1개, 이미지 파트 1개만 허용한다. 그 밖의 필드는 400으로 거절한다. - 변경 규칙: API 스펙 변경 시 Paxo/AI/GeminiService.swift(클라이언트)와 proxy-vercel/api/generate.js(서버)를 반드시 동일한 PR에서 수정한다. 배포는 프록시 서버가 선행되어야 한다. @@ -41,10 +45,12 @@ | 데이터 | 저장 위치 및 방식 | | --- | --- | -| 설정 · 단축키 · 무료 사용량 | UserDefaults | +| 설정 · 단축키 | UserDefaults | | 최근 풀이 기록 | 앱 컨테이너 내 `history.json` (최대 100개, 이미지는 제외) | | 캡처 이미지 | 최근 8장만 메모리에 유지. 디스크에 저장(I/O)하지 않는다. | -| 구독 상태 | StoreKit 2 `Transaction.currentEntitlements` | +| 로그인 유지 정보 | Mac Keychain의 불투명 갱신 토큰. 서버의 Apple 토큰은 AES-256-GCM 암호화 | +| 세션 · 사용량 · 풀이 상태 | Upstash Redis. Production/Preview 접두사 분리, TTL 적용 | +| 구독 상태 | 앱 UI는 StoreKit 2, 실제 AI 권한은 App Store Server API 검증 결과 | | Gemini API 키 | Gemini API 키,프록시 서버의 환경 변수로 관리. 클라이언트 앱에는 절대 포함하지 않는다. | diff --git a/docs/decisions.md b/docs/decisions.md index de91249..cdf113e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,3 +12,6 @@ | 2026-09-13 | 저장소를 공개로 전환했다 | GitHub Actions 무료 · 보호 규칙 · 시크릿 스캔을 쓰기 위해 | 완료 | | 2026-09-13 | 브랜치는 develop(기본, Squash 머지)에서 통합하고 main은 릴리즈 PR(Merge 머지)만 받는다 | 출시본과 개발 중인 코드를 분리한다. 릴리즈 PR을 squash하면 다음 릴리즈에 이미 나간 변경이 다시 뜨므로 main은 Merge만 쓴다 | 채택 | | 2026-09-13 | 공개할 수 없는 문서(운영안 · 제출 절차)는 비공개 저장소 `paxo-app/internal`에 두고 `docs/private/`에 clone한다 | 공개 저장소에 둘 수 없는 내용도 이력 관리가 필요하다 | 채택 | +| 2026-09-25 | Apple 로그인 식별자를 가명화해 서버 세션의 주체로 사용하고 이름·이메일은 요청하지 않는다 | 임의 기기 UUID 우회를 막으면서 개인정보 수집을 최소화한다 | 채택 | +| 2026-09-25 | 무료 3회·Pro 100회 한도를 서울 자정 기준 Redis에서 원자적으로 적용한다 | 서버리스 메모리 초기화와 동시 요청을 이용한 비용 우회를 막는다 | 채택 | +| 2026-09-25 | 하나의 Redis를 쓰되 `paxo:prod`와 `paxo:preview` 접두사를 필수로 강제한다 | 추가 Redis 비용 없이 운영·미리보기 상태 충돌을 막는다 | 채택 | diff --git a/docs/domain.md b/docs/domain.md index a848588..7629c2e 100644 --- a/docs/domain.md +++ b/docs/domain.md @@ -21,14 +21,18 @@ | 용어 | 뜻 | | --- | --- | | 무료 제공량 | 일일 기본 제공 횟수(3회). API를 통한 '정답 요청'이 성공한 경우에만 1회 차감된다. | -| Pro 플랜 | 기능 무제한 사용이 가능한 유료 구독 플랜(월간/연간). 최초 7일간 무료 체험을 제공한다. | +| Pro 플랜 | 월간/연간 유료 구독 플랜. 비용 보호를 위해 정답 성공 기준 하루 최대 100회를 제공한다. 무료 체험 여부는 App Store Connect 설정을 따른다. | | 페이월 (Paywall) | 무료 제공 횟수 소진 시 노출되는 결제 유도 화면. 반드시 화면 캡처가 실행되기 전에 표시되어야 한다.| +| Apple 로그인 | 사용자 인증 수단. 이름·이메일은 요청하지 않으며 Apple 식별자는 서버에서 가명화한다. | +| 서버 권한 | 무료·Pro 여부와 잔여량의 정본. 앱의 로컬 StoreKit 상태는 구매 UI에만 사용한다. | ## 개발 및 정책 용어 | 용어 | 정의 및 규칙 | | --- | --- | | API 스펙 (Contract) | 앱(클라이언트)과 프록시 서버 간의 API 요청/응답 형태. 스펙 변경 시 반드시 양쪽 코드를 동일한 PR에서 수정한다. | +| 풀이 ID (solveId) | 정답과 해당 해설을 연결하는 UUID. 정답 성공 후 같은 ID의 해설 1회만 허용한다. | +| Fail-closed | Redis·Apple 검증 등 권한 확인이 실패하면 Gemini를 호출하지 않는 정책. | | 위험 경로 (Critical Path)| 과금, 프록시, 릴리스 설정 등 핵심 로직과 관련된 파일 경로. 해당 코드 수정 시 머지(Merge) 전 반드시 가디언(지정된 책임 리뷰어)의 승인이 필요하다. | | 기록성 / 규칙성 변경 | 문서 업데이트의 2가지 분류. 기록성 (사실·경로): 리뷰 없이 즉시 머지 가능. 규칙성 (행동 제약): 반드시 가디언 승인 후 머지. | | 레퍼런스 코드 (Reference) | 신규 기능 개발 시 기준이 되는 기존 코드. 각 디렉터리의 AGENTS.md에 해당 경로를 명시하여 참조하도록 한다. | diff --git a/proxy-vercel/.env.example b/proxy-vercel/.env.example new file mode 100644 index 0000000..f56e1b0 --- /dev/null +++ b/proxy-vercel/.env.example @@ -0,0 +1,20 @@ +# 실제 값은 Vercel Environment Variables에 Sensitive로 등록한다. +GEMINI_API_KEY= +APP_TOKEN= +APP_TOKEN_PREV= +MODEL=gemini-3.6-flash +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +REDIS_KEY_PREFIX=paxo:dev +APP_STORE_PRIVATE_KEY= +APP_STORE_KEY_ID= +APP_STORE_ISSUER_ID= +APP_APPLE_ID= +APP_BUNDLE_ID=com.hyeseong.Paxo +APP_STORE_ENVIRONMENT=SANDBOX +IDENTITY_HASH_SECRET= +AUTH_TOKEN_ENCRYPTION_KEY= +APPLE_SIGN_IN_PRIVATE_KEY= +APPLE_SIGN_IN_KEY_ID= +APPLE_TEAM_ID=L3JLLU88WG +APPLE_CLIENT_ID=com.hyeseong.Paxo diff --git a/proxy-vercel/AGENTS.md b/proxy-vercel/AGENTS.md index be30ade..7dec1a1 100644 --- a/proxy-vercel/AGENTS.md +++ b/proxy-vercel/AGENTS.md @@ -8,10 +8,12 @@ **프록시 서버는 클라이언트의 요청 본문(Body)을 그대로 전달(Bypass)하지 않으며, 허용된 필드만 추출하여 재조립(Build Up)한다.** (`api/generate.js` 내 `buildUpstreamBody` 참조) -현재 허용(Whitelist)되는 페이로드 스펙은 정확히 다음과 같다. +`/generate`에서 허용(Whitelist)되는 페이로드 스펙은 정확히 다음과 같다. +* `kind`: `answer` 또는 `explanation` +* `solveId`: UUID v4 * `text` 파트 1개 (최대 8,000자 제한) -* `inline_data` 파트 1개 (`image/jpeg` 또는 `image/png`, Base64 인코딩 기준 최대 6,000,000자 제한) +* `inline_data` 파트 1개 (`image/jpeg` 또는 `image/png`, Base64 인코딩 기준 최대 4,000,000자 제한) `generationConfig`, `systemInstruction`, `safetySettings`, `tools`, 추가 파트(Parts), 다중 텍스트 파트 등은 **구조적으로 전달이 불가능하다.** 클라이언트가 해당 필드를 포함해 요청하더라도 프록시 단에서 무시되거나 HTTP 400 에러를 반환한다. @@ -24,10 +26,12 @@ ```http POST {proxyURL}/generate Content-Type: application/json -x-paxo-device: +Authorization: Bearer <15분 세션 토큰> x-paxo-token: <공유 시크릿(Shared Secret)> { + "kind": "answer | explanation", + "solveId": "", "contents": [ { "parts": [ @@ -42,30 +46,38 @@ x-paxo-token: <공유 시크릿(Shared Secret)> ### 응답 (Response) -* HTTP 200(성공)과 429(Rate Limit) 상태 코드만 클라이언트에 그대로 전달한다. -* 그 외 업스트림(Gemini)에서 발생한 모든 에러 상태는 **HTTP 502(Bad Gateway)로 정규화(Normalize)하여 반환**한다. +* 성공 응답은 `x-paxo-tier`, `x-paxo-remaining`, `x-paxo-reset-at` 헤더를 포함한다. +* Gemini 오류는 502, 타임아웃은 504로 정규화하며 원문 오류는 노출하지 않는다. +* Redis나 Apple 상태 확인 실패는 503으로 fail-closed 한다. * 보안을 위해 업스트림의 상세 에러 본문은 클라이언트에 노출하지 않는다. * 클라이언트가 단일 파서로 에러를 처리할 수 있도록, 에러 반환 포맷은 Gemini 원본과 동일한 `{ "error": { "code", "message" } }` 구조를 유지한다. -## 3. APP_TOKEN 무중단 배포 순서 +## 3. 인증·사용량 정책 -아래 배포 순서 미준수 시 기존 프로덕션 버전 앱에서 401(Unauthorized) 에러가 대량 발생한다. +* `/session/challenge`의 일회용 nonce와 Apple identity token·authorization code로 로그인한다. +* Apple 사용자 식별자는 HMAC 처리하고, Apple 갱신 토큰은 AES-256-GCM으로 암호화한다. +* 무료는 하루 3회, Pro는 하루 100회이며 정답 성공 때만 차감한다. +* 같은 `solveId`의 해설은 정답 성공 후 한 번만 허용하며 추가 차감하지 않는다. +* `APP_TOKEN`은 반드시 설정한다. 누락 상태에서 인증을 우회하는 배포는 금지한다. +* Production과 Preview가 Redis를 공유할 때 `REDIS_KEY_PREFIX`를 각각 `paxo:prod`, `paxo:preview`로 강제한다. -1. **프록시 1차 배포**: `APP_TOKEN` 환경 변수 없이 배포한다. (토큰 검증 로직이 우회되어 기존 앱의 무중단 서비스 가능) -2. **클라이언트 배포**: 신규 토큰 값이 하드코딩된 앱 빌드를 릴리스한다. -3. **프록시 환경 변수 주입**: Vercel 콘솔 또는 CLI를 통해 토큰을 등록한다 (`vercel env add APP_TOKEN production`). -4. **프록시 2차 배포(적용)**: `npx vercel --prod` 명령어로 재배포한다. **이 시점부터 토큰이 없거나 불일치하는 클라이언트 요청은 401 에러로 차단된다.** - -*토큰 로테이션(Rotation)*: 신규 토큰은 `APP_TOKEN`에, 기존 토큰은 `APP_TOKEN_PREV`에 할당하면 구버전 앱의 하위 호환성을 일정 기간 유지할 수 있다. 이 값은 클라이언트의 `Paxo/Secrets.swift` 내 `appToken` 속성과 정확히 일치해야 한다. +`APP_TOKEN_PREV`는 짧은 로테이션 기간에만 사용하고 제거한다. ## 4. 환경 변수 (Environment Variables) | 변수명 | 필수 여부 | 기본값 | 설명 | | --- | --- | --- | --- | | `GEMINI_API_KEY` | **필수** | — | 누락 시 서버 구동 불가 (HTTP 500 에러 발생) | -| `APP_TOKEN` | 선택 | 없음 | 설정된 경우에만 클라이언트 토큰 검증 로직이 활성화됨 | +| `APP_TOKEN` | **필수** | — | 앱 버전 필터용 공유 토큰 | | `APP_TOKEN_PREV` | 선택 | 없음 | 구버전 하위 호환 및 토큰 로테이션 목적의 예비 슬롯 | -| `MODEL` | 선택 | `gemini-3.6-flash` | 호출할 업스트림 LLM 모델 지정 | +| `MODEL` | **필수** | — | 허용 목록 내 Gemini 모델 | +| `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | **필수** | — | 영속 사용량·세션 저장소 | +| `REDIS_KEY_PREFIX` | **필수** | — | `paxo:prod`, `paxo:preview`, 로컬은 `paxo:dev` | +| `APP_STORE_PRIVATE_KEY`, `APP_STORE_KEY_ID`, `APP_STORE_ISSUER_ID` | **필수** | — | In-App Purchase 서버 API 키 | +| `APP_APPLE_ID`, `APP_BUNDLE_ID`, `APP_STORE_ENVIRONMENT` | **필수** | — | 앱 및 StoreKit 검증 환경 | +| `APPLE_SIGN_IN_PRIVATE_KEY`, `APPLE_SIGN_IN_KEY_ID`, `APPLE_TEAM_ID`, `APPLE_CLIENT_ID` | **필수** | — | Apple 로그인 서버 키 | +| `IDENTITY_HASH_SECRET` | **필수** | — | Apple 식별자 HMAC 비밀값 | +| `AUTH_TOKEN_ENCRYPTION_KEY` | **필수** | — | 64자리 hex AES-256 키 | ## 5. 코드 스타일 (Code Convention) @@ -74,9 +86,8 @@ x-paxo-token: <공유 시크릿(Shared Secret)> * **함수 선언**: 헬퍼 함수는 호이스팅(Hoisting)이 가능하도록 `function` 키워드로 선언하며, 화살표 함수(`=>`)는 범위가 제한적인 작은 콜백이나 익명 함수에만 제한적으로 사용한다. * **주석**: 로직의 동작 방식(How)이 아닌 '의도(Why)'를 한국어로 작성한다. (본 프로젝트는 TypeScript가 아니므로 타입 정보보다 의도 전달이 더 중요하다.) -## 6. [레거시] proxy/ (Cloudflare Worker) 디렉터리 정책 - -`proxy/` 디렉터리는 과거 Cloudflare Worker 기반의 백업용 프록시 코드를 담고 있으나, **현재는 폐기(Deprecated)되었다.** +## 6. 로그 금지 데이터 -* **폐기 사유**: Gemini API가 Cloudflare Worker 대역의 요청을 지역(Region) 기반으로 차단하는 이슈가 발생하여 운영 환경에서 배제되었다. 현재의 `proxy-vercel/`은 Vercel `iad1` 리전으로 요청을 고정하여 이 문제를 우회한다. -* **유지 보수 금지**: `proxy/` 디렉터리는 본문 재조립 검증 로직이 누락되어 있으며 에러 정규화 포맷도 다르다. **향후 모든 프록시 관련 신규 개발 및 수정은 반드시 본 `proxy-vercel/` 디렉터리에서만 진행한다.** +raw Apple JWS, identity token, authorization code, 세션·갱신 토큰, App Transaction ID, +프롬프트, 이미지, Gemini 응답은 로그에 남기지 않는다. 가명 subject 앞 12자, 상태 코드, +지연 시간, 요청 크기만 운영 로그에 허용한다. diff --git a/proxy-vercel/README.md b/proxy-vercel/README.md index d7b2b3c..0283181 100644 --- a/proxy-vercel/README.md +++ b/proxy-vercel/README.md @@ -1,69 +1,50 @@ -# proxy-vercel/ — Vercel 기반 API 프록시 서버 +# proxy-vercel — Paxo API 서버 -기존 Cloudflare Workers 버전(`proxy/`)의 아웃바운드 IP가 간헐적으로 Gemini 미지원 지역을 경유하여 "User location is not supported" 에러가 발생하는 치명적 결함을 해결하기 위해 도입된 아키텍처다. -본 Vercel 버전은 **실행 리전을 미국(`iad1`)으로 강제 고정**하여 해당 문제를 구조적으로 원천 차단한다. +Vercel `iad1` 리전에서 Apple 로그인, App Store 구독 검증, 영속 사용량 제한과 Gemini 호출을 +처리한다. Release 앱은 `https://api.paxo.co.kr`만 사용한다. -> **클라이언트 하위 호환성**: 라우팅 경로(`/generate`, `/privacy`) 및 요청/응답 스펙이 기존 Worker 버전과 100% 동일하므로, 프록시 전환에 따른 앱(클라이언트) 코드 수정은 불필요하다. +## 요청 흐름 -## 1. 배포 파이프라인 (Deployment) +1. 앱이 `/session/challenge`에서 일회용 nonce를 받는다. +2. Apple 로그인 결과를 `/session`으로 보내 15분 세션과 30일 갱신 토큰을 받는다. +3. 앱이 Bearer 세션으로 `/generate`를 호출한다. +4. 서버가 Redis에서 무료 3회 또는 Pro 100회 한도를 예약한 뒤 Gemini를 호출한다. +5. 정답 성공 시에만 사용량을 확정하며 같은 `solveId`의 해설 1회는 추가 차감하지 않는다. -소요 시간은 약 5분 내외이며, Vercel CLI를 통해 진행한다. +Apple 사용자 식별자는 HMAC 처리하며 Apple 갱신 토큰은 AES-256-GCM으로 암호화한다. 프롬프트, +이미지, AI 응답과 원본 Apple 식별자는 로그나 Redis에 저장하지 않는다. -```bash -# 1. Vercel CLI 로그인 (계정이 없을 경우 브라우저 창에서 무료 가입/인증 진행) -npx vercel login - -# 2. 프로젝트 최초 배포 (프롬프트 질의 시 모두 기본값(Enter) 사용, 프로젝트명: paxo-proxy) -npx vercel --prod - -# 3. Gemini API 키 환경 변수 주입 -npx vercel env add GEMINI_API_KEY production - -# 4. 환경 변수 적용을 위한 운영(Production) 환경 재배포 -npx vercel --prod - -``` - -배포 완료 후 콘솔에 출력되는 운영 URL(예: `[https://paxo-proxy-xxxx.vercel.app](https://paxo-proxy-xxxx.vercel.app)`)을 클라이언트 프로젝트의 `Paxo/DefaultConfig.swift` 내 `proxyURL` 값으로 설정한다. +## 환경 변수 -## 2. 보안 정책 및 APP_TOKEN 강제화 +실제 값은 모두 Vercel Sensitive 변수로 등록한다. 전체 목록은 `.env.example`을 기준으로 한다. -App Store 심사 제출 전, API 어뷰징을 방지하기 위해 클라이언트 검증 로직을 반드시 강제화(Enforce)해야 한다. `api/generate.js`는 다음 3가지 보안 검증을 수행한다. +- Production: `APP_STORE_ENVIRONMENT=PRODUCTION`, `REDIS_KEY_PREFIX=paxo:prod` +- Preview: `APP_STORE_ENVIRONMENT=SANDBOX`, `REDIS_KEY_PREFIX=paxo:preview` +- 로컬: `APP_STORE_ENVIRONMENT=SANDBOX`, `REDIS_KEY_PREFIX=paxo:dev` -1. **토큰 검증 (`x-paxo-token`)**: 서버 측 `APP_TOKEN` (및 로테이션용 `APP_TOKEN_PREV`) 환경 변수가 설정된 경우에만 일치 여부를 검사한다. **미설정 상태에서는 무조건 통과(Bypass)** 시키므로 무중단 배포에 활용된다. -2. **디바이스 검증 (`x-paxo-device`)**: UUID 포맷 준수 여부를 검사하며, 메모리 기반의 일일 베스트 에포트(Best-effort) 사용량 제한을 적용한다. -3. **페이로드 화이트리스트**: 요청 본문을 분해 후 조립하여 `text` 파트 1개, `inline_data` (jpeg/png) 파트 1개만 허용한다. 클라이언트가 변조한 `generationConfig`나 `safetySettings` 등은 원천 차단된다. +`APP_STORE_*`는 App Store Connect In-App Purchase API 키이고, `APPLE_SIGN_IN_*`는 Apple +Developer에서 만든 Sign in with Apple 키다. 두 개인 키는 서로 바꿔 쓰지 않는다. -### ⚠️ 무중단 보안 적용 순서 (Critical) - -**순서를 위반할 경우 구버전 앱에서 401(Unauthorized) 에러가 대량 발생하므로 절대 엄수한다.** - -1. 프록시 최초 배포 (토큰 환경 변수 **미설정** 상태) -2. 클라이언트(앱) 코드에 토큰 값을 주입하여 앱 배포 (`DefaultConfig.appToken`) -3. 프록시 서버에 토큰 환경 변수 등록 및 재배포 수행 +무작위 비밀값은 다음처럼 생성할 수 있다. ```bash -# 앱에 내장된 토큰과 동일한 값을 프록시에 등록 후 검증 로직 활성화 -npx vercel env add APP_TOKEN production -npx vercel --prod - +openssl rand -hex 32 | npx vercel env add IDENTITY_HASH_SECRET production --sensitive +openssl rand -hex 32 | npx vercel env add AUTH_TOKEN_ENCRYPTION_KEY production --sensitive ``` -## 3. 헬스 체크 (Health Check) +Preview에는 위 명령의 환경 이름만 `preview`로 바꾸고 반드시 새 값을 생성한다. `APP_TOKEN`은 +앱의 `Secrets.appToken`과 같아야 하지만 인증 수단이 아니라 버전 필터일 뿐이다. -프록시 서버 정상 구동 여부 및 권한 제어 로직을 아래 명령어로 검증한다. +## 검증과 배포 ```bash -# 1. 서버 구동 및 정상 라우팅 확인 (200 OK, HTML 반환) -curl -s https://<배포주소>/privacy | head -5 - -# 2. 보안 토큰 검증 로직 작동 확인 (APP_TOKEN 강제화 이후 401 또는 400 반환) -curl -s -X POST https://<배포주소>/generate -d '{}' - +npm ci +npm run check +npm test +npx vercel +npx vercel --prod ``` -## 4. 기술 스펙 및 v2 로드맵 - -* **페이로드 한도**: Vercel의 요청 본문 한도는 4.5MB다. 앱에서 캡처 이미지를 최대 2000px의 JPEG 포맷으로 압축(0.82)하여 전송하므로 해당 제한 내에서 안정적으로 동작한다. -* **Rate Limit 한계점**: 현재 구현된 인메모리(In-memory) 사용량 제한은 컨테이너가 웜(Warm) 상태일 때만 유지되는 임시 조치다. 향후 v2에서는 `checkDurableLimit()` 위치에 Upstash Redis를 연동하여 영속적인 기기별 Rate Limit을 구축할 예정이다. -* **LLM 모델 교체**: 호출 대상 모델을 변경하려면 Vercel 대시보드 또는 CLI를 통해 `MODEL` 환경 변수(예: `gemini-2.5-pro`)를 수정하고 재배포한다. +배포 후 `/privacy`는 200, 인증 없는 `/generate`는 401이어야 한다. Preview에서 Sandbox Apple +로그인과 구매·복원을 먼저 확인한 뒤 Production을 배포한다. Vercel Firewall은 처음에 로그 모드로 +`/session` 10회/분/IP, `/generate` 60회/분/IP를 관찰하고 정상 요청을 확인한 뒤 Enforce로 바꾼다. diff --git a/proxy-vercel/api/account.js b/proxy-vercel/api/account.js new file mode 100644 index 0000000..d12f5cb --- /dev/null +++ b/proxy-vercel/api/account.js @@ -0,0 +1,50 @@ +import { createAppleSignIn } from "../lib/apple-sign-in.js"; +import { loadConfig } from "../lib/config.js"; +import { asHttpError, HttpError } from "../lib/errors.js"; +import { sendError } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { bearerToken, decryptSecret, hashToken, requireAppToken } from "../lib/security.js"; + +function createAccountHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + const appleSignInFactory = dependencies.appleSignInFactory || createAppleSignIn; + const clock = dependencies.clock || (() => new Date()); + + return async function handler(req, res) { + try { + if (req.method !== "DELETE") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const config = configLoader(); + requireAppToken(req, config); + const token = bearerToken(req); + const store = storeFactory(config); + const session = await store.getSession(hashToken(token)); + if (!session || Date.parse(session.expiresAt) <= clock().getTime()) { + throw new HttpError(401, "invalid_session", "session required"); + } + const account = await store.getAccount(session.subject); + if (account) { + const appleRefreshToken = decryptSecret( + account.appleRefreshToken, + config.authTokenEncryptionKey + ); + await appleSignInFactory(config).revoke(appleRefreshToken); + } + await store.deleteSubject(session.subject); + return res.status(204).setHeader("cache-control", "no-store").send(""); + } catch (error) { + const normalized = normalize(error); + return sendError(res, normalized.status, normalized.code, normalized.message); + } + }; +} + +function normalize(error) { + if (error instanceof HttpError) return error; + const normalized = asHttpError(error); + if (normalized.status !== 500) return normalized; + return new HttpError(503, "service_unavailable", "service unavailable"); +} + +export { createAccountHandler }; +export default createAccountHandler(); diff --git a/proxy-vercel/api/generate.js b/proxy-vercel/api/generate.js index 98d3b5d..c589084 100644 --- a/proxy-vercel/api/generate.js +++ b/proxy-vercel/api/generate.js @@ -1,168 +1,252 @@ -// 미국 리전(iad1) 고정 — Gemini "User location is not supported" 회피 (vercel.json). +import { loadConfig, TIER_LIMITS } from "../lib/config.js"; +import { asHttpError, HttpError } from "../lib/errors.js"; +import { sendError, setUsageHeaders } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { + bearerToken, + hashToken, + requestId, + requireAppToken, + seoulUsageWindow, +} from "../lib/security.js"; + export const config = { maxDuration: 60 }; const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"; -const MAX_TEXT_LEN = 8000; // Prompts.swift 최대 프롬프트의 ~10배 여유 -const MAX_IMAGE_B64 = 6_000_000; // 2000px JPEG q0.82 실측 상한의 약 2배 +const MAX_TEXT_LENGTH = 8_000; +const MAX_IMAGE_BASE64_LENGTH = 4_000_000; +const MAX_BODY_LENGTH = 4_100_000; const ALLOWED_MIME = new Set(["image/jpeg", "image/png"]); -const DAILY_LIMIT = 60; // warm instance 한정 베스트에포트 (정식 한도는 향후 Upstash) - -// deviceID -> { count, day } : 서버리스 warm instance 메모리. 콜드 스타트 시 초기화됨. -const memUsage = new Map(); - -export default async function handler(req, res) { - const started = Date.now(); - try { - if (req.method !== "POST") return fail(res, 405, "method not allowed"); - - const accepted = [process.env.APP_TOKEN, process.env.APP_TOKEN_PREV].filter(Boolean); - if (accepted.length && !accepted.includes(req.headers["x-paxo-token"])) { - return fail(res, 401, "unauthorized"); +const SOLVE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function createGenerateHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + const fetcher = dependencies.fetcher || fetch; + const clock = dependencies.clock || (() => new Date()); + + return async function handler(req, res) { + const started = Date.now(); + let reservation; + let store; + let session; + let parsed; + let day; + let resetAt; + try { + if (req.method !== "POST") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const contentLength = Number(req.headers["content-length"] || 0); + if (!Number.isFinite(contentLength) || contentLength > MAX_BODY_LENGTH) { + throw new HttpError(413, "payload_too_large", "payload too large"); + } + + const runtimeConfig = configLoader(); + requireAppToken(req, runtimeConfig); + const token = bearerToken(req); + parsed = buildUpstreamBody(req.body); + store = storeFactory(runtimeConfig); + session = await store.getSession(hashToken(token)); + const now = clock(); + if (!session || Date.parse(session.expiresAt) <= now.getTime()) { + throw new HttpError(401, "invalid_session", "session expired"); + } + + const member = requestId(); + if (!(await store.checkBurst(session.subject, now.getTime(), member))) { + throw new HttpError(429, "burst_limit", "too many requests"); + } + ({ day, resetAt } = seoulUsageWindow(now)); + const limit = TIER_LIMITS[session.tier]; + if (!limit) throw new HttpError(503, "invalid_tier", "service unavailable"); + + if (parsed.kind === "answer") { + reservation = await store.reserveAnswer( + session.subject, + day, + parsed.solveId, + limit, + now.getTime() + ); + } else { + reservation = await store.reserveExplanation(session.subject, parsed.solveId); + } + if (!reservation.allowed) { + if (reservation.reason === "daily_limit") { + setUsageHeaders(res, session.tier, 0, resetAt); + } + throw reservationError(reservation.reason); + } + + let upstream; + try { + upstream = await fetcher(`${GEMINI_BASE}/${runtimeConfig.model}:generateContent`, { + body: JSON.stringify(parsed.upstream), + headers: { + "content-type": "application/json", + "x-goog-api-key": runtimeConfig.geminiApiKey, + }, + method: "POST", + signal: AbortSignal.timeout(45_000), + }); + } catch (error) { + if (error && (error.name === "TimeoutError" || error.name === "AbortError")) throw error; + throw new HttpError(502, "upstream_error", "AI service unavailable"); + } + const responseText = await upstream.text(); + if (upstream.status !== 200) { + await releaseReservation(store, session.subject, day, parsed); + return sendError(res, 502, "upstream_error", "AI service unavailable"); + } + + let remaining; + if (parsed.kind === "answer") { + remaining = await store.finalizeAnswer( + session.subject, + day, + parsed.solveId, + TIER_LIMITS[session.tier] + ); + } else { + await store.finalizeExplanation(session.subject, parsed.solveId); + const used = await store.getUsage(session.subject, day); + remaining = Math.max(0, TIER_LIMITS[session.tier] - used); + } + setUsageHeaders(res, session.tier, remaining, resetAt); + console.log( + JSON.stringify({ + bytesIn: contentLength, + event: "generation_complete", + kind: parsed.kind, + ms: Date.now() - started, + status: 200, + subject: session.subject.slice(0, 12), + }) + ); + return res + .status(200) + .setHeader("content-type", "application/json") + .setHeader("cache-control", "no-store") + .send(responseText); + } catch (error) { + if (reservation && reservation.allowed && store && session && parsed) { + try { + await releaseReservation(store, session.subject, day, parsed); + } catch { + // Redis가 실패한 경우 원래 오류만 반환하고 Gemini 재호출은 허용하지 않는다. + } + } + const normalized = normalizeGenerateError(error); + console.error( + JSON.stringify({ event: "generation_error", code: normalized.code, status: normalized.status }) + ); + return sendError(res, normalized.status, normalized.code, normalized.message); } + }; +} - const device = String(req.headers["x-paxo-device"] || ""); - if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(device)) { - return fail(res, 400, "bad device id"); - } - if (!checkRateLimit(device) || !checkDurableLimit(device)) { - return fail(res, 429, "rate limited"); +function buildUpstreamBody(raw) { + badUnless(raw && typeof raw === "object" && !Array.isArray(raw), "invalid body"); + const rootKeys = Object.keys(raw).sort(); + badUnless( + JSON.stringify(rootKeys) === JSON.stringify(["contents", "kind", "solveId"]), + "unsupported body field" + ); + badUnless(new Set(["answer", "explanation"]).has(raw.kind), "invalid kind"); + badUnless(typeof raw.solveId === "string" && SOLVE_ID_PATTERN.test(raw.solveId), "invalid solve id"); + badUnless(Array.isArray(raw.contents) && raw.contents.length === 1, "invalid contents"); + badUnless( + raw.contents[0] && + typeof raw.contents[0] === "object" && + !Array.isArray(raw.contents[0]) && + Object.keys(raw.contents[0]).length === 1 && + Object.hasOwn(raw.contents[0], "parts"), + "invalid contents" + ); + const parts = raw.contents[0].parts; + badUnless(Array.isArray(parts) && parts.length === 2, "invalid parts"); + + let text; + let image; + for (const part of parts) { + badUnless(part && typeof part === "object" && !Array.isArray(part), "unsupported part"); + if (Object.keys(part).length === 1 && typeof part.text === "string") { + badUnless(text === undefined && part.text.length > 0 && part.text.length <= MAX_TEXT_LENGTH, "invalid text"); + text = { text: part.text }; + continue; } - - if (!process.env.GEMINI_API_KEY) return fail(res, 500, "server misconfigured"); - - const body = buildUpstreamBody(req.body); - - const model = process.env.MODEL || "gemini-3.6-flash"; - const upstream = await fetch(`${GEMINI_BASE}/${model}:generateContent`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-goog-api-key": process.env.GEMINI_API_KEY, - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(45_000), - }); - const text = await upstream.text(); - - console.log( - JSON.stringify({ - device: device.slice(0, 8), - status: upstream.status, - ms: Date.now() - started, - bytesIn: Number(req.headers["content-length"] || 0), - }) + const inline = part.inline_data; + badUnless( + Object.keys(part).length === 1 && + inline && + typeof inline === "object" && + !Array.isArray(inline) && + JSON.stringify(Object.keys(inline).sort()) === JSON.stringify(["data", "mime_type"]), + "unsupported image" ); - - if (upstream.status === 200 || upstream.status === 429) { - return res - .status(upstream.status) - .setHeader("content-type", "application/json") - .send(text); - } - // Gemini의 원문은 사용자에게 노출하지 않되, 운영 로그에서 키·모델·권한 문제를 구분한다. - const diagnostic = upstreamDiagnostic(text); - console.error( - JSON.stringify({ - event: "gemini_upstream_error", - status: upstream.status, - providerStatus: diagnostic.providerStatus, - providerCode: diagnostic.providerCode, - providerMessage: diagnostic.providerMessage, - }) + badUnless(image === undefined && ALLOWED_MIME.has(inline.mime_type), "unsupported mime type"); + badUnless( + typeof inline.data === "string" && + inline.data.length > 0 && + inline.data.length <= MAX_IMAGE_BASE64_LENGTH && + inline.data.length % 4 === 0 && + /^[A-Za-z0-9+/]+={0,2}$/.test(inline.data), + "invalid image data" ); - return fail(res, 502, "upstream error"); - } catch (err) { - if (err && (err.name === "TimeoutError" || err.name === "AbortError")) { - return fail(res, 504, "upstream timeout"); - } - if (err && err.status === 400) return fail(res, 400, err.message); - console.error("generate error:", err && err.message); - return fail(res, 500, "internal error"); + const bytes = Buffer.from(inline.data, "base64"); + badUnless(matchesMime(bytes, inline.mime_type), "image signature mismatch"); + image = { inline_data: { data: inline.data, mime_type: inline.mime_type } }; } + badUnless(text && image, "text and image required"); + return { + kind: raw.kind, + solveId: raw.solveId, + upstream: { + contents: [{ parts: [text, image] }], + generationConfig: { maxOutputTokens: raw.kind === "answer" ? 256 : 1024 }, + }, + }; } -function upstreamDiagnostic(text) { - try { - const parsed = JSON.parse(text); - const error = parsed && typeof parsed === "object" ? parsed.error : null; - if (!error || typeof error !== "object") { - return { providerStatus: "unknown", providerCode: null, providerMessage: "unknown" }; - } - return { - providerStatus: typeof error.status === "string" ? error.status : "unknown", - providerCode: Number.isInteger(error.code) ? error.code : null, - providerMessage: - typeof error.message === "string" ? error.message.slice(0, 300) : "unknown", - }; - } catch { - return { providerStatus: "unknown", providerCode: null, providerMessage: "unknown" }; +function matchesMime(bytes, mime) { + if (mime === "image/jpeg") { + return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; } + return ( + bytes.length >= 8 && + bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ); } -// Gemini 오류 형식과 동형 — GeminiService.serverMessage가 이 형태 하나만 파싱한다. -function fail(res, code, message) { - return res - .status(code) - .setHeader("content-type", "application/json") - .send(JSON.stringify({ error: { code, message } })); +function badUnless(condition, message) { + if (!condition) throw new HttpError(400, "invalid_request", message); } -// 허용: contents[0].parts[] 에서 { text } 1개 + { inline_data:{mime_type,data} } 1개. -// generationConfig / safetySettings / systemInstruction / tools 등은 조립에 포함 불가. -function buildUpstreamBody(raw) { - const bad = (message) => { - throw { status: 400, message }; - }; - if (!raw || typeof raw !== "object") bad("invalid body"); - - const contents = raw.contents; - if (!Array.isArray(contents) || contents.length !== 1) bad("invalid contents"); - - const parts = contents[0] && contents[0].parts; - if (!Array.isArray(parts) || parts.length === 0 || parts.length > 2) bad("invalid parts"); - - const outParts = []; - let hasText = false; - let hasImage = false; - - for (const part of parts) { - if (part && typeof part.text === "string") { - if (hasText) bad("duplicate text part"); - if (part.text.length > MAX_TEXT_LEN) bad("text too long"); - outParts.push({ text: part.text }); - hasText = true; - } else if (part && part.inline_data && typeof part.inline_data === "object") { - if (hasImage) bad("duplicate image part"); - const mime = part.inline_data.mime_type; - const data = part.inline_data.data; - if (!ALLOWED_MIME.has(mime)) bad("unsupported mime type"); - if (typeof data !== "string" || !/^[A-Za-z0-9+/=]+$/.test(data)) bad("invalid image data"); - if (data.length > MAX_IMAGE_B64) bad("image too large"); - outParts.push({ inline_data: { mime_type: mime, data } }); - hasImage = true; - } else { - bad("unsupported part"); - } +function reservationError(reason) { + if (reason === "daily_limit") return new HttpError(429, "daily_limit", "daily limit reached"); + if (reason === "answer_required") return new HttpError(409, "answer_required", "answer required"); + if (reason === "duplicate" || reason === "in_flight") { + return new HttpError(409, "duplicate_request", "request already processed"); } - if (!hasText || !hasImage) bad("text and image required"); - - return { contents: [{ parts: outParts }] }; + return new HttpError(503, "service_unavailable", "service unavailable"); } -function checkRateLimit(device) { - const day = new Date().toISOString().slice(0, 10); - const entry = memUsage.get(device); - if (!entry || entry.day !== day) { - memUsage.set(device, { count: 1, day }); - if (memUsage.size > 10_000) memUsage.clear(); // 메모리 폭주 방지 - return true; +async function releaseReservation(store, subject, day, parsed) { + if (parsed.kind === "answer") { + await store.releaseAnswer(subject, day, parsed.solveId); + } else { + await store.releaseExplanation(subject, parsed.solveId); } - entry.count += 1; - return entry.count <= DAILY_LIMIT; } -// 내구성 있는 기기별 한도 자리(현재 no-op). 향후 Upstash Redis를 여기에 끼운다. -// eslint-disable-next-line no-unused-vars -function checkDurableLimit(device) { - return true; +function normalizeGenerateError(error) { + if (error && (error.name === "TimeoutError" || error.name === "AbortError")) { + return new HttpError(504, "upstream_timeout", "AI service timed out"); + } + if (error instanceof HttpError) return error; + const normalized = asHttpError(error); + if (normalized.status !== 500) return normalized; + return new HttpError(503, "service_unavailable", "service unavailable"); } + +export { buildUpstreamBody, createGenerateHandler }; +export default createGenerateHandler(); diff --git a/proxy-vercel/api/privacy.js b/proxy-vercel/api/privacy.js index 992d1db..67a66a7 100644 --- a/proxy-vercel/api/privacy.js +++ b/proxy-vercel/api/privacy.js @@ -1,6 +1,5 @@ -// 개인정보 처리방침 페이지 (Cloudflare 워커 버전과 동일 내용) - export default function handler(req, res) { + if (req.method !== "GET") return res.status(405).send("method not allowed"); res.setHeader("content-type", "text/html; charset=utf-8").send(PRIVACY_HTML); } @@ -12,46 +11,59 @@ const PRIVACY_HTML = ` Paxo 개인정보 처리방침

Paxo 개인정보 처리방침

-

시행일: 2026년 7월 21일

+

시행일: 2026년 9월 25일

-

수집·처리하는 정보

+

처리하는 정보

    -
  • 캡처 이미지: 사용자가 단축키로 직접 캡처한 화면 이미지는 AI 풀이 생성을 위해 - 서버로 전송됩니다. 이미지는 풀이 생성에만 사용되며, Paxo 서버에 저장되지 않고 - 처리 즉시 폐기됩니다.
  • -
  • 익명 기기 식별자: 무작위 생성된 식별자를 사용량 제한(어뷰징 방지) 목적으로만 - 사용합니다. 개인을 식별할 수 없으며 다른 정보와 연결되지 않습니다.
  • -
  • Paxo는 계정 가입을 요구하지 않으며 이름, 이메일 등 개인정보를 수집하지 않습니다.
  • +
  • 화면 캡처 이미지와 풀이 요청: 사용자가 풀이를 실행할 때 Google Gemini API로 + 전달됩니다. Paxo는 이 콘텐츠를 파일이나 데이터베이스에 저장하지 않습니다. 요청 처리를 위해 + Vercel과 Google이 일시적으로 데이터를 처리할 수 있으며 각 제공자의 정책이 적용됩니다.
  • +
  • Apple 로그인 정보: Apple이 발급한 사용자 식별자는 HMAC으로 가명화한 뒤 + 사용합니다. 이름과 이메일 권한은 요청하지 않습니다. 로그인 유지용 Apple 토큰은 암호화해 + 최대 30일 보관하며 계정 사용 시 갱신됩니다.
  • +
  • 구독 확인 정보: App Store가 서명한 앱·구독 정보를 검증합니다. 원본 App + Transaction ID는 저장하지 않고 가명화한 연결 정보만 사용합니다.
  • +
  • 사용량·보안 정보: 세션, 일일 사용량, 풀이 요청 상태는 콘텐츠 없이 Redis에 + 저장됩니다. 풀이 상태와 사용량은 최대 48시간, 로그인 유지 정보는 최대 30일 보관합니다. + Vercel 방화벽은 악용 방지를 위해 IP 주소와 요청 메타데이터를 처리할 수 있습니다.
-

제3자 처리

-

AI 풀이 생성을 위해 캡처 이미지가 Google Gemini API로 전달됩니다. -Google의 데이터 처리에 관한 내용은 -Google Gemini API 약관을 참고하세요.

+

이용 목적과 제공량

+

위 정보는 로그인, 무료·Pro 권한 확인, 일일 사용량 제한, 중복 요청 방지와 AI 풀이 생성에만 +사용합니다. 무료 사용자는 하루 3회, Pro 사용자는 비용 보호를 위해 하루 최대 100회 풀이할 수 +있으며 해설 1회는 해당 풀이에 포함됩니다.

-

결제

-

구독 결제는 Apple App Store를 통해 처리되며, Paxo는 결제 정보에 접근하지 않습니다.

+

제3자 처리

+

서비스 제공을 위해 Apple(App Store 및 Apple 로그인), Vercel(서버 호스팅), Upstash(Redis), +Google(Gemini API)이 요청을 처리합니다. 각 제공자의 보관·처리 정책은 해당 서비스의 약관과 +개인정보 처리방침을 따릅니다.

로컬 저장 데이터

-

풀이 기록(정답·해설 텍스트)은 사용자의 기기에만 저장되며 서버로 전송되지 않습니다.

+

최근 풀이의 정답·해설 텍스트와 앱 설정은 사용자 Mac에 저장됩니다. 캡처 이미지는 디스크에 +저장하지 않습니다. 로그아웃이나 서버 계정 삭제만으로 로컬 풀이 기록은 삭제되지 않으며 사용자가 +앱 데이터를 제거해 삭제할 수 있습니다.

+ +

계정 삭제

+

앱 설정의 ‘계정 삭제’를 사용하면 Apple 로그인 토큰을 폐기하고 Paxo 서버의 계정·세션·사용량 +정보를 삭제합니다. 기기에 저장된 풀이 기록은 별도로 유지됩니다.

문의

개인정보 관련 문의: kimsonghansung@gmail.com


Privacy Policy (English Summary)

-

Paxo does not require an account and collects no personal information. -Screen captures you explicitly take are sent to our server solely to generate AI explanations, -forwarded to the Google Gemini API, and are never stored — they are discarded immediately after processing. -A randomly generated anonymous device identifier is used only for rate limiting. -Solve history is stored locally on your device only. Payments are handled by the Apple App Store. -Contact: kimsonghansung@gmail.com

+

Paxo uses Sign in with Apple without requesting name or email. The Apple user identifier is +pseudonymized, and the Apple refresh token is encrypted for up to 30 days. Screen captures are sent +through Vercel to Google Gemini only to generate an answer and are not persistently stored by Paxo. +Session, quota, and solve-state records contain no image or prompt content and expire within 48 hours; +login continuity records expire within 30 days. Free accounts receive 3 solves per day and Pro accounts +receive up to 100. Account deletion is available in the app. Contact: kimsonghansung@gmail.com

`; diff --git a/proxy-vercel/api/session-challenge.js b/proxy-vercel/api/session-challenge.js new file mode 100644 index 0000000..5e83cc7 --- /dev/null +++ b/proxy-vercel/api/session-challenge.js @@ -0,0 +1,32 @@ +import { loadConfig } from "../lib/config.js"; +import { asHttpError } from "../lib/errors.js"; +import { sendError } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { newOpaqueToken, requireAppToken, sha256 } from "../lib/security.js"; + +function createChallengeHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + const tokenFactory = dependencies.tokenFactory || newOpaqueToken; + + return async function handler(req, res) { + try { + if (req.method !== "POST") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const config = configLoader(); + requireAppToken(req, config); + const nonce = tokenFactory(); + await storeFactory(config).putChallenge(sha256(nonce)); + return res + .status(200) + .setHeader("content-type", "application/json") + .setHeader("cache-control", "no-store") + .send(JSON.stringify({ nonce })); + } catch (error) { + const normalized = asHttpError(error, 503, "service_unavailable", "service unavailable"); + return sendError(res, normalized.status, normalized.code, normalized.message); + } + }; +} + +export { createChallengeHandler }; +export default createChallengeHandler(); diff --git a/proxy-vercel/api/session-logout.js b/proxy-vercel/api/session-logout.js new file mode 100644 index 0000000..a758877 --- /dev/null +++ b/proxy-vercel/api/session-logout.js @@ -0,0 +1,35 @@ +import { loadConfig } from "../lib/config.js"; +import { asHttpError, HttpError } from "../lib/errors.js"; +import { sendError } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { bearerToken, hashToken, requireAppToken } from "../lib/security.js"; + +function createLogoutHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + + return async function handler(req, res) { + try { + if (req.method !== "POST") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const config = configLoader(); + requireAppToken(req, config); + const refreshToken = req.body && req.body.refreshToken; + if (typeof refreshToken !== "string" || refreshToken.length === 0 || refreshToken.length > 256) { + throw new HttpError(400, "invalid_request", "invalid logout request"); + } + const store = storeFactory(config); + const deletes = [store.deleteRefresh(hashToken(refreshToken))]; + if (req.headers.authorization) { + deletes.push(store.deleteSession(hashToken(bearerToken(req)))); + } + await Promise.all(deletes); + return res.status(204).setHeader("cache-control", "no-store").send(""); + } catch (error) { + const normalized = asHttpError(error, 503, "service_unavailable", "service unavailable"); + return sendError(res, normalized.status, normalized.code, normalized.message); + } + }; +} + +export { createLogoutHandler }; +export default createLogoutHandler(); diff --git a/proxy-vercel/api/session-refresh.js b/proxy-vercel/api/session-refresh.js new file mode 100644 index 0000000..7fe8b8a --- /dev/null +++ b/proxy-vercel/api/session-refresh.js @@ -0,0 +1,92 @@ +import { createAppleSignIn } from "../lib/apple-sign-in.js"; +import { createAppleStore } from "../lib/apple-store.js"; +import { loadConfig } from "../lib/config.js"; +import { asHttpError, HttpError } from "../lib/errors.js"; +import { sendError } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { decryptSecret, hashToken, requireAppToken } from "../lib/security.js"; +import { issueSession, parseRefreshBody, resolveTier } from "../lib/session-service.js"; + +const APPLE_VALIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000; + +function createRefreshHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + const appleSignInFactory = dependencies.appleSignInFactory || createAppleSignIn; + const appleStoreFactory = dependencies.appleStoreFactory || createAppleStore; + const clock = dependencies.clock || (() => new Date()); + const tokenFactory = dependencies.tokenFactory; + + return async function handler(req, res) { + let consumedRefresh; + let consumedTokenHash; + let store; + try { + if (req.method !== "POST") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const config = configLoader(); + requireAppToken(req, config); + const body = parseRefreshBody(req.body); + store = storeFactory(config); + const tokenHash = hashToken(body.refreshToken); + const refresh = await store.consumeRefresh(tokenHash); + if (!refresh || Date.parse(refresh.expiresAt) <= clock().getTime()) { + throw new HttpError(401, "invalid_refresh", "sign in required"); + } + consumedRefresh = refresh; + consumedTokenHash = tokenHash; + const account = await store.getAccount(refresh.subject); + if (!account) throw new HttpError(401, "invalid_refresh", "sign in required"); + + if (clock().getTime() - Date.parse(account.validatedAt) >= APPLE_VALIDATION_INTERVAL_MS) { + const appleRefreshToken = decryptSecret( + account.appleRefreshToken, + config.authTokenEncryptionKey + ); + await appleSignInFactory(config).validateRefreshToken(appleRefreshToken); + await store.putAccount(refresh.subject, { ...account, validatedAt: clock().toISOString() }); + } + + const tier = await resolveTier({ + appleStore: appleStoreFactory(config), + config, + now: clock(), + proof: body.storeKitProof, + store, + subject: refresh.subject, + }); + const session = await issueSession({ + clock, + store, + subject: refresh.subject, + tier, + tokenFactory, + }); + consumedRefresh = undefined; + return res + .status(200) + .setHeader("content-type", "application/json") + .setHeader("cache-control", "no-store") + .send(JSON.stringify(session)); + } catch (error) { + const normalized = normalize(error); + if (normalized.status >= 500 && consumedRefresh && consumedTokenHash && store) { + try { + await store.putRefresh(consumedTokenHash, consumedRefresh); + } catch { + // 저장소 장애 중에는 토큰 복구보다 fail-closed 정책을 우선한다. + } + } + return sendError(res, normalized.status, normalized.code, normalized.message); + } + }; +} + +function normalize(error) { + if (error instanceof HttpError) return error; + const normalized = asHttpError(error); + if (normalized.status !== 500) return normalized; + return new HttpError(503, "service_unavailable", "service unavailable"); +} + +export { createRefreshHandler }; +export default createRefreshHandler(); diff --git a/proxy-vercel/api/session.js b/proxy-vercel/api/session.js new file mode 100644 index 0000000..c2392ec --- /dev/null +++ b/proxy-vercel/api/session.js @@ -0,0 +1,70 @@ +import { createAppleSignIn } from "../lib/apple-sign-in.js"; +import { createAppleStore } from "../lib/apple-store.js"; +import { loadConfig } from "../lib/config.js"; +import { asHttpError, HttpError } from "../lib/errors.js"; +import { sendError } from "../lib/http.js"; +import { createRedisStore } from "../lib/redis-store.js"; +import { encryptSecret, hashSubject, requireAppToken, sha256 } from "../lib/security.js"; +import { issueSession, parseSessionBody, resolveTier } from "../lib/session-service.js"; + +function createSessionHandler(dependencies = {}) { + const configLoader = dependencies.configLoader || loadConfig; + const storeFactory = dependencies.storeFactory || createRedisStore; + const appleSignInFactory = dependencies.appleSignInFactory || createAppleSignIn; + const appleStoreFactory = dependencies.appleStoreFactory || createAppleStore; + const clock = dependencies.clock || (() => new Date()); + const tokenFactory = dependencies.tokenFactory; + + return async function handler(req, res) { + try { + if (req.method !== "POST") return sendError(res, 405, "method_not_allowed", "method not allowed"); + const config = configLoader(); + requireAppToken(req, config); + const { proof, storeKitProof } = parseSessionBody(req.body); + const store = storeFactory(config); + if (!(await store.consumeChallenge(sha256(proof.nonce)))) { + throw new HttpError(401, "invalid_challenge", "Apple sign in challenge expired"); + } + + const appleSignIn = appleSignInFactory(config); + const appleSubject = await appleSignIn.verifyIdentityToken(proof.identityToken, proof.nonce); + const subject = hashSubject(`apple:${appleSubject}`, config.identityHashSecret); + const appleRefreshToken = await appleSignIn.exchangeAuthorizationCode(proof.authorizationCode); + const appleStore = appleStoreFactory(config); + const tier = await resolveTier({ + appleStore, + config, + now: clock(), + proof: storeKitProof, + store, + subject, + }); + await store.putAccount(subject, { + appleRefreshToken: encryptSecret(appleRefreshToken, config.authTokenEncryptionKey), + validatedAt: clock().toISOString(), + }); + const session = await issueSession({ clock, store, subject, tier, tokenFactory }); + + console.log(JSON.stringify({ event: "session_issued", subject: subject.slice(0, 12), tier })); + return res + .status(200) + .setHeader("content-type", "application/json") + .setHeader("cache-control", "no-store") + .send(JSON.stringify(session)); + } catch (error) { + const normalized = normalizeSessionError(error); + console.error(JSON.stringify({ event: "session_error", code: normalized.code, status: normalized.status })); + return sendError(res, normalized.status, normalized.code, normalized.message); + } + }; +} + +function normalizeSessionError(error) { + if (error instanceof HttpError) return error; + const normalized = asHttpError(error); + if (normalized.status !== 500) return normalized; + return new HttpError(503, "service_unavailable", "service unavailable"); +} + +export { createSessionHandler, parseSessionBody }; +export default createSessionHandler(); diff --git a/proxy-vercel/certs/apple-root-certificates.pem b/proxy-vercel/certs/apple-root-certificates.pem new file mode 100644 index 0000000..80c667f --- /dev/null +++ b/proxy-vercel/certs/apple-root-certificates.pem @@ -0,0 +1,108 @@ +-----BEGIN CERTIFICATE----- +MIIFujCCBKKgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBhjELMAkGA1UEBhMCVVMx +HTAbBgNVBAoTFEFwcGxlIENvbXB1dGVyLCBJbmMuMS0wKwYDVQQLEyRBcHBsZSBD +b21wdXRlciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxKTAnBgNVBAMTIEFwcGxlIFJv +b3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MB4XDTA1MDIxMDAwMTgxNFoXDTI1MDIx +MDAwMTgxNFowgYYxCzAJBgNVBAYTAlVTMR0wGwYDVQQKExRBcHBsZSBDb21wdXRl +ciwgSW5jLjEtMCsGA1UECxMkQXBwbGUgQ29tcHV0ZXIgQ2VydGlmaWNhdGUgQXV0 +aG9yaXR5MSkwJwYDVQQDEyBBcHBsZSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 +eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOSRqQkfkdseR1DrBe1e +eYQt6zaiV0xV7IsZid75S2z1B6siMALoGD74UAnTf0GomPnRymacJGsR0KO75Bsq +wx+VnnoMpEeLW9QWNzPLxA9NzhRp0ckZcvVdDtV/X5vyJQO6VY9NXQ3xZDUjFUsV +WR2zlPf2nJ7PULrBWFBnjwi0IPfLrCwgb3C2PwEwjLdDzw+dPfMrSSgayP7OtbkO +2V4c1ss9tTqt9A8OAJILsSEWLnTVPA3bYharo3GSR1NVwa8vQbP4++NwzeajTEV+ +H0xrUJZBicR0YgsQg0GHM4qBsTBY7FoEMoxos48d3mVz/2deZbxJ2HafMxRloXeU +yS0CAwEAAaOCAi8wggIrMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBQr0GlHlHYJ/vRrjS5ApvdHTX8IXjAfBgNVHSMEGDAWgBQr0GlH +lHYJ/vRrjS5ApvdHTX8IXjCCASkGA1UdIASCASAwggEcMIIBGAYJKoZIhvdjZAUB +MIIBCTBBBggrBgEFBQcCARY1aHR0cHM6Ly93d3cuYXBwbGUuY29tL2NlcnRpZmlj +YXRlYXV0aG9yaXR5L3Rlcm1zLmh0bWwwgcMGCCsGAQUFBwICMIG2GoGzUmVsaWFu +Y2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2Nl +cHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRhcmQgdGVybXMgYW5k +IGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNlcnRp +ZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wRAYDVR0fBD0wOzA5oDegNYYz +aHR0cHM6Ly93d3cuYXBwbGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5L3Jvb3Qu +Y3JsMFUGCCsGAQUFBwEBBEkwRzBFBggrBgEFBQcwAoY5aHR0cHM6Ly93d3cuYXBw +bGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5L2Nhc2lnbmVycy5odG1sMA0GCSqG +SIb3DQEBBQUAA4IBAQCd2i0oWC99dgS5BNM+zrdmY06PL9T+S61yvaM5xlJNBZhS +9YlRASR5vhoy9+VEi0tEBzmC1lrKtCBe2a4VXR2MHTK/ODFiSF3H4ZCx+CRA+F9Y +m1FdV53B5f88zHIhbsTp6aF31ywXJsM/65roCwO66bNKcuszCVut5mIxauivL9Wv +Hld2j383LS4CXN1jyfJxuCZA3xWNdUQ/eb3mHZnhQyw+rW++uaT+DjUZUWOxw961 +kj5ReAFziqQjyqSI8R5cH0EWLX6VCqrpiUGYGxrdyyC/R14MJsVVNU3GMIuZZxTH +CR+6R8faAQmHJEKVvRNgGQrv6n8Obs3BREM6StXj +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET +MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0 +MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw +bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+ ++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1 +XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w +tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW +q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM +aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3 +R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE +ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93 +d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl +IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0 +YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj +b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp +Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc +NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP +y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7 +R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg +xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP +IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX +UKqK1drk/NAJBzewdXUh +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UE +AwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0 +aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMw +HhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBs +ZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0 +aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6s +XuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5x +ZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu +OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVd +J2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tv +ny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz +8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TU +gWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFm +IrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOe +HwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLD +d4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8 +DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm +jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwF +AAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+ +8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVA +eKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3z +ybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG +/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6Z +SYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePw +N983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDr +FEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0 +Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60E +inpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiK +um1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- diff --git a/proxy-vercel/lib/apple-sign-in.js b/proxy-vercel/lib/apple-sign-in.js new file mode 100644 index 0000000..fa75ea8 --- /dev/null +++ b/proxy-vercel/lib/apple-sign-in.js @@ -0,0 +1,183 @@ +import { createPublicKey, sign, verify } from "node:crypto"; + +import { HttpError } from "./errors.js"; +import { sha256 } from "./security.js"; + +const APPLE_ISSUER = "https://appleid.apple.com"; +const APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; +const APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; +const APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; +const CLIENT_SECRET_TTL_SECONDS = 5 * 60; +const JWKS_TTL_MS = 60 * 60 * 1000; + +let cachedKeys; +let cachedKeysAt = 0; + +class AppleSignIn { + constructor(config, fetcher = fetch, clock = () => new Date()) { + this.config = config; + this.fetcher = fetcher; + this.clock = clock; + } + + async verifyIdentityToken(identityToken, rawNonce) { + const { header, payload, signingInput, signature } = parseJWT(identityToken); + if (header.alg !== "RS256" || typeof header.kid !== "string") { + throw new HttpError(401, "invalid_apple_identity", "Apple identity verification failed"); + } + let keys = await this.loadKeys(); + let jwk = keys.find((candidate) => candidate.kid === header.kid && candidate.kty === "RSA"); + if (!jwk) { + keys = await this.loadKeys(true); + jwk = keys.find((candidate) => candidate.kid === header.kid && candidate.kty === "RSA"); + } + if (!jwk || !verify("RSA-SHA256", signingInput, createPublicKey({ key: jwk, format: "jwk" }), signature)) { + throw new HttpError(401, "invalid_apple_identity", "Apple identity verification failed"); + } + + const now = Math.floor(this.clock().getTime() / 1000); + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if ( + payload.iss !== APPLE_ISSUER || + !audiences.includes(this.config.appleClientId) || + !Number.isFinite(payload.exp) || + payload.exp <= now || + typeof payload.sub !== "string" || + payload.sub.length === 0 || + payload.nonce !== sha256(rawNonce) + ) { + throw new HttpError(401, "invalid_apple_identity", "Apple identity verification failed"); + } + return payload.sub; + } + + async exchangeAuthorizationCode(code) { + const response = await this.tokenRequest({ code, grant_type: "authorization_code" }); + if (typeof response.refresh_token !== "string" || response.refresh_token.length === 0) { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + return response.refresh_token; + } + + async validateRefreshToken(refreshToken) { + await this.tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }); + } + + async revoke(refreshToken) { + let response; + try { + response = await this.fetcher(APPLE_REVOKE_URL, { + body: new URLSearchParams({ + client_id: this.config.appleClientId, + client_secret: this.clientSecret(), + token: refreshToken, + token_type_hint: "refresh_token", + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + method: "POST", + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + if (!response.ok) { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + } + + async tokenRequest(parameters) { + let response; + try { + response = await this.fetcher(APPLE_TOKEN_URL, { + body: new URLSearchParams({ + client_id: this.config.appleClientId, + client_secret: this.clientSecret(), + ...parameters, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + method: "POST", + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + let body; + try { + body = await response.json(); + } catch { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + if (!response.ok) { + if (body && body.error === "invalid_grant") { + throw new HttpError(401, "apple_credential_revoked", "Apple credential is no longer valid"); + } + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + return body; + } + + clientSecret() { + const now = Math.floor(this.clock().getTime() / 1000); + const header = encodeJSON({ alg: "ES256", kid: this.config.appleSignInKeyId, typ: "JWT" }); + const payload = encodeJSON({ + aud: APPLE_ISSUER, + exp: now + CLIENT_SECRET_TTL_SECONDS, + iat: now, + iss: this.config.appleTeamId, + sub: this.config.appleClientId, + }); + const input = `${header}.${payload}`; + const signature = sign("sha256", Buffer.from(input), { + dsaEncoding: "ieee-p1363", + key: this.config.appleSignInPrivateKey, + }); + return `${input}.${signature.toString("base64url")}`; + } + + async loadKeys(forceRefresh = false) { + if (!forceRefresh && cachedKeys && this.clock().getTime() - cachedKeysAt < JWKS_TTL_MS) { + return cachedKeys; + } + try { + const response = await this.fetcher(APPLE_KEYS_URL, { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error("Apple JWKS unavailable"); + const body = await response.json(); + if (!Array.isArray(body.keys)) throw new Error("Invalid Apple JWKS"); + cachedKeys = body.keys; + cachedKeysAt = this.clock().getTime(); + return cachedKeys; + } catch { + throw new HttpError(503, "apple_sign_in_unavailable", "Apple sign in unavailable"); + } + } +} + +function parseJWT(value) { + if (typeof value !== "string" || value.length > 20_000) { + throw new HttpError(400, "invalid_apple_identity", "invalid Apple identity token"); + } + const parts = value.split("."); + if (parts.length !== 3) { + throw new HttpError(400, "invalid_apple_identity", "invalid Apple identity token"); + } + try { + return { + header: JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")), + payload: JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")), + signature: Buffer.from(parts[2], "base64url"), + signingInput: Buffer.from(`${parts[0]}.${parts[1]}`), + }; + } catch { + throw new HttpError(400, "invalid_apple_identity", "invalid Apple identity token"); + } +} + +function encodeJSON(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function createAppleSignIn(config) { + return new AppleSignIn(config); +} + +export { AppleSignIn, createAppleSignIn, parseJWT }; diff --git a/proxy-vercel/lib/apple-store.js b/proxy-vercel/lib/apple-store.js new file mode 100644 index 0000000..53025c8 --- /dev/null +++ b/proxy-vercel/lib/apple-store.js @@ -0,0 +1,132 @@ +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; + +import { + APIError, + APIException, + AppStoreServerAPIClient, + Environment, + SignedDataVerifier, + Status, +} from "@apple/app-store-server-library"; + +import { HttpError } from "./errors.js"; + +const PRODUCT_IDS = new Set([ + "com.hyeseong.Paxo.pro.monthly", + "com.hyeseong.Paxo.pro.yearly", +]); +const FREE_STATUS_ERRORS = new Set([APIError.ACCOUNT_NOT_FOUND, APIError.STATUS_REQUEST_NOT_FOUND]); + +function loadRootCertificates() { + const pem = readFileSync(new URL("../certs/apple-root-certificates.pem", import.meta.url), "utf8"); + const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) || []; + if (blocks.length === 0) throw new Error("Apple root certificates are missing"); + return blocks.map((block) => new X509Certificate(block).raw); +} + +class AppleStore { + constructor(verifier, client, environment, bundleId, appAppleId) { + this.verifier = verifier; + this.client = client; + this.environment = environment; + this.bundleId = bundleId; + this.appAppleId = appAppleId; + } + + async verifyAppTransaction(jws) { + try { + const transaction = await this.verifier.verifyAndDecodeAppTransaction(jws); + if (!transaction.appTransactionId) throw new Error("missing app transaction id"); + if (transaction.bundleId !== this.bundleId) throw new Error("bundle mismatch"); + if (this.appAppleId && transaction.appAppleId !== this.appAppleId) { + throw new Error("app id mismatch"); + } + return transaction.appTransactionId; + } catch (error) { + throw new HttpError(401, "invalid_storekit_proof", "App Store verification failed"); + } + } + + async verifyTransaction(jws) { + try { + const transaction = await this.verifier.verifyAndDecodeTransaction(jws); + if ( + !transaction.originalTransactionId || + !PRODUCT_IDS.has(transaction.productId) || + transaction.bundleId !== this.bundleId + ) { + throw new Error("transaction mismatch"); + } + return transaction.originalTransactionId; + } catch { + throw new HttpError(401, "invalid_storekit_proof", "App Store verification failed"); + } + } + + async resolveTier(appTransactionId, now = Date.now()) { + let response; + try { + response = await this.client.getAllSubscriptionStatuses(appTransactionId, [ + Status.ACTIVE, + Status.BILLING_GRACE_PERIOD, + ]); + } catch (error) { + if ( + error instanceof APIException && + error.httpStatusCode === 404 && + FREE_STATUS_ERRORS.has(error.apiError) + ) { + return "free"; + } + throw new HttpError(503, "app_store_unavailable", "App Store verification unavailable"); + } + + if (response.bundleId !== this.bundleId || response.environment !== this.environment) { + throw new HttpError(503, "app_store_invalid_response", "App Store verification unavailable"); + } + if (this.appAppleId && response.appAppleId !== this.appAppleId) { + throw new HttpError(503, "app_store_invalid_response", "App Store verification unavailable"); + } + + for (const group of response.data || []) { + for (const item of group.lastTransactions || []) { + if (!item.signedTransactionInfo) continue; + const transaction = await this.verifier.verifyAndDecodeTransaction(item.signedTransactionInfo); + if (!PRODUCT_IDS.has(transaction.productId) || transaction.revocationDate) continue; + + if (item.status === Status.ACTIVE && Number(transaction.expiresDate || 0) > now) { + return "pro"; + } + if (item.status === Status.BILLING_GRACE_PERIOD && item.signedRenewalInfo) { + const renewal = await this.verifier.verifyAndDecodeRenewalInfo(item.signedRenewalInfo); + if (Number(renewal.gracePeriodExpiresDate || 0) > now) return "pro"; + } + } + } + return "free"; + } +} + +function createAppleStore(config) { + const environment = + config.appStoreEnvironment === "PRODUCTION" ? Environment.PRODUCTION : Environment.SANDBOX; + const roots = loadRootCertificates(); + const verifier = new SignedDataVerifier( + roots, + true, + environment, + config.appBundleId, + config.appAppleId + ); + const client = new AppStoreServerAPIClient( + config.appStorePrivateKey, + config.appStoreKeyId, + config.appStoreIssuerId, + config.appBundleId, + environment + ); + return new AppleStore(verifier, client, environment, config.appBundleId, config.appAppleId); +} + +export { AppleStore, PRODUCT_IDS, createAppleStore }; diff --git a/proxy-vercel/lib/config.js b/proxy-vercel/lib/config.js new file mode 100644 index 0000000..2edd52d --- /dev/null +++ b/proxy-vercel/lib/config.js @@ -0,0 +1,114 @@ +import { HttpError } from "./errors.js"; + +const ALLOWED_MODELS = new Set(["gemini-3.6-flash"]); +const EXPECTED_BUNDLE_ID = "com.hyeseong.Paxo"; +const TIER_LIMITS = { free: 3, pro: 100 }; +const REQUIRED_KEYS = [ + "GEMINI_API_KEY", + "APP_TOKEN", + "MODEL", + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + "REDIS_KEY_PREFIX", + "APP_STORE_PRIVATE_KEY", + "APP_STORE_KEY_ID", + "APP_STORE_ISSUER_ID", + "APP_BUNDLE_ID", + "APP_STORE_ENVIRONMENT", + "IDENTITY_HASH_SECRET", + "AUTH_TOKEN_ENCRYPTION_KEY", + "APPLE_SIGN_IN_PRIVATE_KEY", + "APPLE_SIGN_IN_KEY_ID", + "APPLE_TEAM_ID", + "APPLE_CLIENT_ID", +]; + +function loadConfig(env = process.env) { + const missing = REQUIRED_KEYS.filter((key) => !String(env[key] || "").trim()); + if (missing.length > 0) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + const environment = String(env.APP_STORE_ENVIRONMENT).toUpperCase(); + if (!new Set(["PRODUCTION", "SANDBOX"]).has(environment)) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + if ( + (env.VERCEL_ENV === "production" && environment !== "PRODUCTION") || + (env.VERCEL_ENV === "preview" && environment !== "SANDBOX") + ) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + if ( + String(env.APP_BUNDLE_ID) !== EXPECTED_BUNDLE_ID || + String(env.APPLE_CLIENT_ID) !== EXPECTED_BUNDLE_ID + ) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + if (Buffer.byteLength(String(env.IDENTITY_HASH_SECRET), "utf8") < 32) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + if (!/^[0-9a-fA-F]{64}$/.test(String(env.AUTH_TOKEN_ENCRYPTION_KEY))) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + let redisURL; + try { + redisURL = new URL(String(env.UPSTASH_REDIS_REST_URL)); + } catch { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + if (redisURL.protocol !== "https:") { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + const redisKeyPrefix = String(env.REDIS_KEY_PREFIX); + const expectedPrefix = + env.VERCEL_ENV === "production" + ? "paxo:prod" + : env.VERCEL_ENV === "preview" + ? "paxo:preview" + : "paxo:dev"; + if (redisKeyPrefix !== expectedPrefix) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + const appAppleId = Number(env.APP_APPLE_ID); + if (environment === "PRODUCTION" && (!Number.isSafeInteger(appAppleId) || appAppleId <= 0)) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + const model = String(env.MODEL); + if (!ALLOWED_MODELS.has(model)) { + throw new HttpError(503, "server_misconfigured", "service unavailable"); + } + + return { + appAppleId: environment === "PRODUCTION" ? appAppleId : undefined, + appBundleId: String(env.APP_BUNDLE_ID), + appStoreEnvironment: environment, + appStoreIssuerId: String(env.APP_STORE_ISSUER_ID), + appStoreKeyId: String(env.APP_STORE_KEY_ID), + appStorePrivateKey: normalizePEM(env.APP_STORE_PRIVATE_KEY), + appToken: String(env.APP_TOKEN), + appTokenPrevious: String(env.APP_TOKEN_PREV || ""), + appleClientId: String(env.APPLE_CLIENT_ID), + appleSignInKeyId: String(env.APPLE_SIGN_IN_KEY_ID), + appleSignInPrivateKey: normalizePEM(env.APPLE_SIGN_IN_PRIVATE_KEY), + appleTeamId: String(env.APPLE_TEAM_ID), + authTokenEncryptionKey: String(env.AUTH_TOKEN_ENCRYPTION_KEY), + geminiApiKey: String(env.GEMINI_API_KEY), + identityHashSecret: String(env.IDENTITY_HASH_SECRET), + model, + redisKeyPrefix, + redisToken: String(env.UPSTASH_REDIS_REST_TOKEN), + redisURL: redisURL.toString().replace(/\/$/, ""), + }; +} + +function normalizePEM(value) { + return String(value).replace(/\\n/g, "\n"); +} + +export { ALLOWED_MODELS, TIER_LIMITS, loadConfig }; diff --git a/proxy-vercel/lib/errors.js b/proxy-vercel/lib/errors.js new file mode 100644 index 0000000..0f23171 --- /dev/null +++ b/proxy-vercel/lib/errors.js @@ -0,0 +1,15 @@ +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.name = "HttpError"; + this.status = status; + this.code = code; + } +} + +function asHttpError(error, status = 500, code = "internal_error", message = "internal error") { + if (error instanceof HttpError) return error; + return new HttpError(status, code, message); +} + +export { HttpError, asHttpError }; diff --git a/proxy-vercel/lib/http.js b/proxy-vercel/lib/http.js new file mode 100644 index 0000000..020f34f --- /dev/null +++ b/proxy-vercel/lib/http.js @@ -0,0 +1,15 @@ +function sendError(res, status, code, message) { + return res + .status(status) + .setHeader("content-type", "application/json") + .setHeader("cache-control", "no-store") + .send(JSON.stringify({ error: { code, message } })); +} + +function setUsageHeaders(res, tier, remaining, resetAt) { + res.setHeader("x-paxo-tier", tier); + res.setHeader("x-paxo-remaining", String(remaining)); + res.setHeader("x-paxo-reset-at", resetAt); +} + +export { sendError, setUsageHeaders }; diff --git a/proxy-vercel/lib/redis-store.js b/proxy-vercel/lib/redis-store.js new file mode 100644 index 0000000..6587d8f --- /dev/null +++ b/proxy-vercel/lib/redis-store.js @@ -0,0 +1,278 @@ +import { Redis } from "@upstash/redis"; + +const STATE_TTL_SECONDS = 60 * 60 * 48; +const SESSION_TTL_SECONDS = 60 * 15; +const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 30; +const CHALLENGE_TTL_SECONDS = 5 * 60; +const BURST_WINDOW_MS = 60 * 1000; +const RESERVATION_TTL_MS = 60 * 1000; + +const CONSUME_VALUE = ` +local value = redis.call("GET", KEYS[1]) +if not value then return false end +redis.call("DEL", KEYS[1]) +return value +`; + +const TRACK_KEY = ` +redis.call("SADD", KEYS[1], ARGV[1]) +local current = redis.call("TTL", KEYS[1]) +local requested = tonumber(ARGV[2]) +if current < requested then redis.call("EXPIRE", KEYS[1], requested) end +return 1 +`; + +const CHECK_BURST = ` +redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", tonumber(ARGV[1]) - tonumber(ARGV[2])) +local count = redis.call("ZCARD", KEYS[1]) +if count >= tonumber(ARGV[3]) then return 0 end +redis.call("ZADD", KEYS[1], tonumber(ARGV[1]), ARGV[4]) +redis.call("EXPIRE", KEYS[1], 120) +return 1 +`; + +const RESERVE_ANSWER = ` +redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", tonumber(ARGV[1]) - tonumber(ARGV[2])) +if redis.call("EXISTS", KEYS[3]) == 1 then return {0, "duplicate", 0} end +local used = tonumber(redis.call("GET", KEYS[1]) or "0") +local pending = redis.call("ZCARD", KEYS[2]) +local limit = tonumber(ARGV[3]) +if used + pending >= limit then return {0, "daily_limit", math.max(0, limit - used)} end +local added = redis.call("ZADD", KEYS[2], "NX", tonumber(ARGV[1]), ARGV[4]) +if added == 0 then return {0, "in_flight", math.max(0, limit - used)} end +redis.call("EXPIRE", KEYS[2], tonumber(ARGV[5])) +return {1, "ok", math.max(0, limit - used)} +`; + +const FINALIZE_ANSWER = ` +local removed = redis.call("ZREM", KEYS[2], ARGV[1]) +if removed == 0 then return {-1, 0} end +if redis.call("EXISTS", KEYS[4]) == 0 then return {-2, 0} end +local used = redis.call("INCR", KEYS[1]) +redis.call("EXPIRE", KEYS[1], tonumber(ARGV[3])) +redis.call("SET", KEYS[3], "1", "EX", tonumber(ARGV[3])) +return {used, math.max(0, tonumber(ARGV[2]) - used)} +`; + +const RESERVE_EXPLANATION = ` +if redis.call("EXISTS", KEYS[1]) == 0 then return {0, "answer_required"} end +if redis.call("EXISTS", KEYS[2]) == 1 then return {0, "duplicate"} end +local reserved = redis.call("SET", KEYS[3], "1", "NX", "PX", tonumber(ARGV[1])) +if not reserved then return {0, "in_flight"} end +return {1, "ok"} +`; + +const FINALIZE_EXPLANATION = ` +if redis.call("DEL", KEYS[1]) == 0 then return 0 end +if redis.call("EXISTS", KEYS[3]) == 0 then return -1 end +redis.call("SET", KEYS[2], "1", "EX", tonumber(ARGV[1])) +return 1 +`; + +class RedisStore { + constructor(redis, prefix) { + this.redis = redis; + this.prefix = prefix; + this.consumeValueScript = redis.createScript(CONSUME_VALUE); + this.trackKeyScript = redis.createScript(TRACK_KEY); + this.checkBurstScript = redis.createScript(CHECK_BURST); + this.reserveAnswerScript = redis.createScript(RESERVE_ANSWER); + this.finalizeAnswerScript = redis.createScript(FINALIZE_ANSWER); + this.reserveExplanationScript = redis.createScript(RESERVE_EXPLANATION); + this.finalizeExplanationScript = redis.createScript(FINALIZE_EXPLANATION); + } + + key(...parts) { + return [this.prefix, ...parts].join(":"); + } + + async putChallenge(nonceHash) { + await this.redis.set(this.key("challenge", nonceHash), "1", { ex: CHALLENGE_TTL_SECONDS }); + } + + async consumeChallenge(nonceHash) { + const result = await this.consumeValueScript.exec([this.key("challenge", nonceHash)], []); + return Boolean(result); + } + + async putSession(tokenHash, session) { + const key = this.key("session", tokenHash); + await Promise.all([ + this.redis.set(key, JSON.stringify(session), { ex: SESSION_TTL_SECONDS }), + this.trackKey(session.subject, key, SESSION_TTL_SECONDS), + ]); + } + + async getSession(tokenHash) { + return this.readJSON(this.key("session", tokenHash)); + } + + async deleteSession(tokenHash) { + await this.redis.del(this.key("session", tokenHash)); + } + + async putRefresh(tokenHash, refresh) { + const key = this.key("refresh", tokenHash); + await Promise.all([ + this.redis.set(key, JSON.stringify(refresh), { ex: REFRESH_TTL_SECONDS }), + this.trackKey(refresh.subject, key, REFRESH_TTL_SECONDS), + ]); + } + + async getRefresh(tokenHash) { + return this.readJSON(this.key("refresh", tokenHash)); + } + + async consumeRefresh(tokenHash) { + const value = await this.consumeValueScript.exec([this.key("refresh", tokenHash)], []); + if (!value) return null; + return typeof value === "string" ? JSON.parse(value) : value; + } + + async deleteRefresh(tokenHash) { + await this.redis.del(this.key("refresh", tokenHash)); + } + + async putAccount(subject, account) { + const key = this.key("account", subject); + await Promise.all([ + this.redis.set(key, JSON.stringify(account), { ex: REFRESH_TTL_SECONDS }), + this.trackKey(subject, key, REFRESH_TTL_SECONDS), + ]); + } + + async getAccount(subject) { + return this.readJSON(this.key("account", subject)); + } + + async bindAppTransaction(transactionHash, subject) { + const key = this.key("app-transaction", transactionHash); + const bound = await this.redis.set(key, subject, { nx: true, ex: REFRESH_TTL_SECONDS }); + const matches = Boolean(bound) || (await this.redis.get(key)) === subject; + if (matches) await this.trackKey(subject, key, REFRESH_TTL_SECONDS); + return matches; + } + + async deleteSubject(subject) { + const indexKey = this.key("subject-keys", subject); + const keys = await this.redis.smembers(indexKey); + if (keys.length > 0) await this.redis.del(...keys); + await this.redis.del(indexKey); + } + + async getUsage(subject, day) { + return Number((await this.redis.get(this.key("usage", subject, day))) || 0); + } + + async checkBurst(subject, now, member) { + const key = this.key("burst", subject); + const result = await this.checkBurstScript.exec( + [key], + [String(now), String(BURST_WINDOW_MS), "10", member] + ); + await this.trackKey(subject, key, 120); + return Number(result) === 1; + } + + async reserveAnswer(subject, day, solveId, limit, now) { + const pendingKey = this.key("answer-pending", subject, day); + const result = await this.reserveAnswerScript.exec( + [ + this.key("usage", subject, day), + pendingKey, + this.key("answer-done", subject, solveId), + ], + [ + String(now), + String(RESERVATION_TTL_MS), + String(limit), + solveId, + String(STATE_TTL_SECONDS), + ] + ); + if (Number(result[0]) === 1) await this.trackKey(subject, pendingKey, STATE_TTL_SECONDS); + return { allowed: Number(result[0]) === 1, reason: String(result[1]), remaining: Number(result[2]) }; + } + + async finalizeAnswer(subject, day, solveId, limit) { + const usageKey = this.key("usage", subject, day); + const doneKey = this.key("answer-done", subject, solveId); + const result = await this.finalizeAnswerScript.exec( + [ + usageKey, + this.key("answer-pending", subject, day), + doneKey, + this.key("account", subject), + ], + [solveId, String(limit), String(STATE_TTL_SECONDS)] + ); + if (Number(result[0]) < 0) throw new Error("answer reservation expired"); + await Promise.all([ + this.trackKey(subject, usageKey, STATE_TTL_SECONDS), + this.trackKey(subject, doneKey, STATE_TTL_SECONDS), + ]); + return Number(result[1]); + } + + async releaseAnswer(subject, day, solveId) { + await this.redis.zrem(this.key("answer-pending", subject, day), solveId); + } + + async reserveExplanation(subject, solveId) { + const pendingKey = this.key("explanation-pending", subject, solveId); + const result = await this.reserveExplanationScript.exec( + [ + this.key("answer-done", subject, solveId), + this.key("explanation-done", subject, solveId), + pendingKey, + ], + [String(RESERVATION_TTL_MS)] + ); + if (Number(result[0]) === 1) await this.trackKey(subject, pendingKey, STATE_TTL_SECONDS); + return { allowed: Number(result[0]) === 1, reason: String(result[1]) }; + } + + async finalizeExplanation(subject, solveId) { + const doneKey = this.key("explanation-done", subject, solveId); + const result = await this.finalizeExplanationScript.exec( + [ + this.key("explanation-pending", subject, solveId), + doneKey, + this.key("account", subject), + ], + [String(STATE_TTL_SECONDS)] + ); + if (Number(result) !== 1) throw new Error("explanation reservation expired"); + await this.trackKey(subject, doneKey, STATE_TTL_SECONDS); + } + + async releaseExplanation(subject, solveId) { + await this.redis.del(this.key("explanation-pending", subject, solveId)); + } + + async readJSON(key) { + const value = await this.redis.get(key); + if (!value) return null; + return typeof value === "string" ? JSON.parse(value) : value; + } + + async trackKey(subject, key, ttl) { + const indexKey = this.key("subject-keys", subject); + await this.trackKeyScript.exec([indexKey], [key, String(ttl)]); + } +} + +let cachedStore; +let cachedIdentity; + +function createRedisStore(config) { + const identity = `${config.redisURL}|${config.redisKeyPrefix}`; + if (!cachedStore || cachedIdentity !== identity) { + const redis = new Redis({ url: config.redisURL, token: config.redisToken }); + cachedStore = new RedisStore(redis, config.redisKeyPrefix); + cachedIdentity = identity; + } + return cachedStore; +} + +export { RedisStore, createRedisStore }; diff --git a/proxy-vercel/lib/security.js b/proxy-vercel/lib/security.js new file mode 100644 index 0000000..0ba2c88 --- /dev/null +++ b/proxy-vercel/lib/security.js @@ -0,0 +1,109 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; + +import { HttpError } from "./errors.js"; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +function constantTimeEqual(left, right) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function requireAppToken(req, config) { + const provided = String(req.headers["x-paxo-token"] || ""); + const accepted = [config.appToken, config.appTokenPrevious].filter(Boolean); + if (accepted.length === 0 || !accepted.some((token) => constantTimeEqual(provided, token))) { + throw new HttpError(401, "unauthorized", "unauthorized"); + } +} + +function bearerToken(req) { + const authorization = String(req.headers.authorization || ""); + const match = authorization.match(/^Bearer ([A-Za-z0-9_-]{32,})$/); + if (!match) throw new HttpError(401, "invalid_session", "session required"); + return match[1]; +} + +function newOpaqueToken() { + return randomBytes(32).toString("base64url"); +} + +function hashToken(token) { + return createHash("sha256").update(token).digest("hex"); +} + +function hashSubject(identifier, secret) { + return createHmac("sha256", secret).update(identifier).digest("hex"); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function encryptSecret(value, hexKey) { + const key = Buffer.from(hexKey, "hex"); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), ciphertext].map((part) => part.toString("base64url")).join("."); +} + +function decryptSecret(value, hexKey) { + try { + const [ivText, tagText, ciphertextText, extra] = value.split("."); + if (!ivText || !tagText || !ciphertextText || extra) throw new Error("invalid ciphertext"); + const decipher = createDecipheriv( + "aes-256-gcm", + Buffer.from(hexKey, "hex"), + Buffer.from(ivText, "base64url") + ); + decipher.setAuthTag(Buffer.from(tagText, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(ciphertextText, "base64url")), + decipher.final(), + ]).toString("utf8"); + } catch { + throw new HttpError(503, "credential_unavailable", "service unavailable"); + } +} + +function seoulUsageWindow(now = new Date()) { + const shifted = new Date(now.getTime() + KST_OFFSET_MS); + const day = shifted.toISOString().slice(0, 10); + const resetAt = new Date( + Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) - KST_OFFSET_MS + ); + return { day, resetAt: resetAt.toISOString() }; +} + +function requestId() { + return randomUUID(); +} + +const hashSessionToken = hashToken; +const newSessionToken = newOpaqueToken; + +export { + bearerToken, + decryptSecret, + encryptSecret, + hashSubject, + hashSessionToken, + hashToken, + newSessionToken, + newOpaqueToken, + requestId, + requireAppToken, + seoulUsageWindow, + sha256, +}; diff --git a/proxy-vercel/lib/session-service.js b/proxy-vercel/lib/session-service.js new file mode 100644 index 0000000..c32d2a3 --- /dev/null +++ b/proxy-vercel/lib/session-service.js @@ -0,0 +1,100 @@ +import { TIER_LIMITS } from "./config.js"; +import { HttpError } from "./errors.js"; +import { hashSubject, hashToken, newOpaqueToken, seoulUsageWindow } from "./security.js"; + +const SESSION_TTL_MS = 15 * 60 * 1000; +const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const APP_TRANSACTION_MAX_LENGTH = 20_000; + +async function resolveTier({ appleStore, config, proof, store, subject, now }) { + if (proof === undefined || proof === null) return "free"; + if ( + typeof proof !== "object" || + Array.isArray(proof) || + JSON.stringify(Object.keys(proof).sort()) !== JSON.stringify(["jws", "type"]) || + !new Set(["appTransaction", "transaction"]).has(proof.type) || + typeof proof.jws !== "string" || + proof.jws.length > APP_TRANSACTION_MAX_LENGTH || + proof.jws.split(".").length !== 3 + ) { + throw new HttpError(400, "invalid_storekit_proof", "invalid StoreKit proof"); + } + const transactionId = + proof.type === "appTransaction" + ? await appleStore.verifyAppTransaction(proof.jws) + : await appleStore.verifyTransaction(proof.jws); + const transactionHash = hashSubject(`transaction:${transactionId}`, config.identityHashSecret); + if (!(await store.bindAppTransaction(transactionHash, subject))) { + throw new HttpError(409, "storekit_account_mismatch", "App Store account is already linked"); + } + return appleStore.resolveTier(transactionId, now.getTime()); +} + +async function issueSession({ clock, store, subject, tier, tokenFactory = newOpaqueToken }) { + const now = clock(); + const { day, resetAt } = seoulUsageWindow(now); + const used = await store.getUsage(subject, day); + const sessionToken = tokenFactory(); + const refreshToken = tokenFactory(); + const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString(); + const refreshExpiresAt = new Date(now.getTime() + REFRESH_TTL_MS).toISOString(); + await Promise.all([ + store.putSession(hashToken(sessionToken), { expiresAt, subject, tier }), + store.putRefresh(hashToken(refreshToken), { expiresAt: refreshExpiresAt, subject }), + ]); + return { + expiresAt, + refreshToken, + remainingToday: Math.max(0, TIER_LIMITS[tier] - used), + resetAt, + sessionToken, + tier, + }; +} + +function parseSessionBody(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new HttpError(400, "invalid_proof", "invalid Apple sign in proof"); + } + const allowed = new Set(["proof", "storeKitProof"]); + if (Object.keys(raw).some((key) => !allowed.has(key))) { + throw new HttpError(400, "invalid_proof", "invalid Apple sign in proof"); + } + const proof = raw.proof; + if (!proof || typeof proof !== "object" || Array.isArray(proof)) { + throw new HttpError(400, "invalid_proof", "invalid Apple sign in proof"); + } + const proofKeys = Object.keys(proof).sort(); + if ( + JSON.stringify(proofKeys) !== + JSON.stringify(["authorizationCode", "identityToken", "nonce", "type"]) + ) { + throw new HttpError(400, "invalid_proof", "invalid Apple sign in proof"); + } + if ( + proof.type !== "appleIdentity" || + !validString(proof.identityToken, 20_000) || + !validString(proof.authorizationCode, 4_000) || + !validString(proof.nonce, 256) + ) { + throw new HttpError(400, "invalid_proof", "invalid Apple sign in proof"); + } + return { proof, storeKitProof: raw.storeKitProof }; +} + +function parseRefreshBody(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new HttpError(400, "invalid_request", "invalid refresh request"); + } + const allowed = new Set(["refreshToken", "storeKitProof"]); + if (Object.keys(raw).some((key) => !allowed.has(key)) || !validString(raw.refreshToken, 256)) { + throw new HttpError(400, "invalid_request", "invalid refresh request"); + } + return raw; +} + +function validString(value, maximum) { + return typeof value === "string" && value.length > 0 && value.length <= maximum; +} + +export { issueSession, parseRefreshBody, parseSessionBody, resolveTier }; diff --git a/proxy-vercel/package-lock.json b/proxy-vercel/package-lock.json new file mode 100644 index 0000000..68113d1 --- /dev/null +++ b/proxy-vercel/package-lock.json @@ -0,0 +1,535 @@ +{ + "name": "paxo-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paxo-proxy", + "version": "1.0.0", + "dependencies": { + "@apple/app-store-server-library": "3.1.0", + "@upstash/redis": "1.39.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apple/app-store-server-library": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apple/app-store-server-library/-/app-store-server-library-3.1.0.tgz", + "integrity": "sha512-d26SICRz8BwCV2qPR0BSXBMxmw0NEvJLwsdREcBmCrus8NmyHw2XgOOP0fiFjcctPn/JXtmSDuGVnaHe+dqO+A==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.5", + "@types/jsrsasign": "^10.5.12", + "@types/node": "^25.4.0", + "@types/node-fetch": "^2.6.13", + "base64url": "^3.0.1", + "jsonwebtoken": "^9.0.2", + "jsrsasign": "^11.0.0", + "node-fetch": "^2.7.0" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsrsasign": { + "version": "10.5.15", + "resolved": "https://registry.npmjs.org/@types/jsrsasign/-/jsrsasign-10.5.15.tgz", + "integrity": "sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.8.tgz", + "integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@upstash/redis": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.39.0.tgz", + "integrity": "sha512-VwkpYTdxSfjSulQS5h16NHtg/1UEheBfvez2AeGKinAujOoGXhXCcohQRzsQ/d2g/XzbjzTVGhG8Ds8uOI0Mxg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsrsasign": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.5.tgz", + "integrity": "sha512-i6mAjey0/4UQmlOaNS2vROl8EkWV8Ei436reJAby2OPQ3lF6uXSs1VYpnM8hndmmCMFIzNc04Uw2GF+0JVLFbg==", + "deprecated": "This package is no longer maintained.", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/proxy-vercel/package.json b/proxy-vercel/package.json new file mode 100644 index 0000000..e65bc74 --- /dev/null +++ b/proxy-vercel/package.json @@ -0,0 +1,17 @@ +{ + "name": "paxo-proxy", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test test/*.test.js", + "check": "for file in api/*.js lib/*.js; do node --check \"$file\" || exit 1; done" + }, + "dependencies": { + "@apple/app-store-server-library": "3.1.0", + "@upstash/redis": "1.39.0" + } +} diff --git a/proxy-vercel/test/apple-sign-in.test.js b/proxy-vercel/test/apple-sign-in.test.js new file mode 100644 index 0000000..6752159 --- /dev/null +++ b/proxy-vercel/test/apple-sign-in.test.js @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import test from "node:test"; + +import { AppleSignIn } from "../lib/apple-sign-in.js"; +import { sha256 } from "../lib/security.js"; +import { baseConfig } from "./helpers.js"; + +const NOW = new Date("2026-09-22T03:00:00.000Z"); +const { privateKey: identityPrivateKey, publicKey: identityPublicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, +}); +const { privateKey: clientPrivateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const jwk = identityPublicKey.export({ format: "jwk" }); +jwk.kid = "apple-key"; +jwk.alg = "RS256"; + +function identityToken(overrides = {}) { + const header = encode({ alg: "RS256", kid: "apple-key" }); + const payload = encode({ + aud: "com.hyeseong.Paxo", + exp: Math.floor(NOW.getTime() / 1000) + 300, + iss: "https://appleid.apple.com", + nonce: sha256("raw-nonce"), + sub: "apple-user", + ...overrides, + }); + const input = `${header}.${payload}`; + const signature = sign("RSA-SHA256", Buffer.from(input), identityPrivateKey); + return `${input}.${signature.toString("base64url")}`; +} + +function createSignIn() { + const config = baseConfig({ + appleSignInPrivateKey: clientPrivateKey.export({ format: "pem", type: "pkcs8" }), + }); + const fetcher = async (url) => { + assert.equal(url, "https://appleid.apple.com/auth/keys"); + return { json: async () => ({ keys: [jwk] }), ok: true }; + }; + return new AppleSignIn(config, fetcher, () => NOW); +} + +test("Apple identity token의 서명, audience, nonce와 만료를 검증한다", async () => { + const apple = createSignIn(); + assert.equal(await apple.verifyIdentityToken(identityToken(), "raw-nonce"), "apple-user"); + await assert.rejects(apple.verifyIdentityToken(identityToken(), "wrong-nonce")); + await assert.rejects(apple.verifyIdentityToken(identityToken({ aud: "other.app" }), "raw-nonce")); + await assert.rejects(apple.verifyIdentityToken(identityToken({ exp: 1 }), "raw-nonce")); +}); + +test("서명된 client secret은 Apple 전용 claim을 사용한다", () => { + const token = createSignIn().clientSecret(); + const [headerPart, payloadPart] = token.split("."); + const header = JSON.parse(Buffer.from(headerPart, "base64url").toString("utf8")); + const payload = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf8")); + assert.equal(header.alg, "ES256"); + assert.equal(payload.iss, "L3JLLU88WG"); + assert.equal(payload.sub, "com.hyeseong.Paxo"); + assert.equal(payload.aud, "https://appleid.apple.com"); + assert.ok(payload.exp - payload.iat <= 300); +}); + +function encode(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} diff --git a/proxy-vercel/test/apple-store.test.js b/proxy-vercel/test/apple-store.test.js new file mode 100644 index 0000000..263bd2b --- /dev/null +++ b/proxy-vercel/test/apple-store.test.js @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Environment, Status } from "@apple/app-store-server-library"; + +import { AppleStore } from "../lib/apple-store.js"; + +const BUNDLE_ID = "com.hyeseong.Paxo"; +const APP_ID = 1234567890; +const NOW = 1_800_000_000_000; + +function createStore({ appTransaction, response, renewal, transaction } = {}) { + const verifier = { + verifyAndDecodeAppTransaction: async () => + appTransaction || { + appAppleId: APP_ID, + appTransactionId: "app-transaction-id", + bundleId: BUNDLE_ID, + }, + verifyAndDecodeRenewalInfo: async () => renewal || {}, + verifyAndDecodeTransaction: async () => transaction || {}, + }; + const client = { + getAllSubscriptionStatuses: async () => + response || { + appAppleId: APP_ID, + bundleId: BUNDLE_ID, + data: [], + environment: Environment.PRODUCTION, + }, + }; + return new AppleStore(verifier, client, Environment.PRODUCTION, BUNDLE_ID, APP_ID); +} + +test("AppTransaction 번들 및 App Apple ID가 일치해야 한다", async () => { + assert.equal(await createStore().verifyAppTransaction("jws"), "app-transaction-id"); + await assert.rejects( + createStore({ appTransaction: { appAppleId: APP_ID, appTransactionId: "id", bundleId: "other" } }) + .verifyAppTransaction("jws"), + (error) => error.status === 401 && error.code === "invalid_storekit_proof" + ); +}); + +test("macOS 14 구독 트랜잭션은 원본 트랜잭션 ID로 검증한다", async () => { + const store = createStore({ + transaction: { + bundleId: BUNDLE_ID, + originalTransactionId: "original-transaction-id", + productId: "com.hyeseong.Paxo.pro.monthly", + }, + }); + assert.equal(await store.verifyTransaction("jws"), "original-transaction-id"); + await assert.rejects( + createStore({ + transaction: { + bundleId: "other.bundle", + originalTransactionId: "id", + productId: "com.hyeseong.Paxo.pro.monthly", + }, + }).verifyTransaction("jws") + ); +}); + +test("Active와 Billing Grace Period만 Pro로 판정한다", async () => { + const active = createStore({ + response: { + appAppleId: APP_ID, + bundleId: BUNDLE_ID, + data: [{ lastTransactions: [{ signedTransactionInfo: "transaction", status: Status.ACTIVE }] }], + environment: Environment.PRODUCTION, + }, + transaction: { expiresDate: NOW + 60_000, productId: "com.hyeseong.Paxo.pro.monthly" }, + }); + assert.equal(await active.resolveTier("id", NOW), "pro"); + + const grace = createStore({ + renewal: { gracePeriodExpiresDate: NOW + 60_000 }, + response: { + appAppleId: APP_ID, + bundleId: BUNDLE_ID, + data: [{ + lastTransactions: [{ + signedRenewalInfo: "renewal", + signedTransactionInfo: "transaction", + status: Status.BILLING_GRACE_PERIOD, + }], + }], + environment: Environment.PRODUCTION, + }, + transaction: { productId: "com.hyeseong.Paxo.pro.yearly" }, + }); + assert.equal(await grace.resolveTier("id", NOW), "pro"); +}); + +test("만료, 환불, 알 수 없는 상품은 무료로 판정한다", async () => { + for (const transaction of [ + { expiresDate: NOW - 1, productId: "com.hyeseong.Paxo.pro.monthly" }, + { expiresDate: NOW + 60_000, productId: "com.hyeseong.Paxo.pro.monthly", revocationDate: NOW }, + { expiresDate: NOW + 60_000, productId: "other.product" }, + ]) { + const store = createStore({ + response: { + appAppleId: APP_ID, + bundleId: BUNDLE_ID, + data: [{ lastTransactions: [{ signedTransactionInfo: "transaction", status: Status.ACTIVE }] }], + environment: Environment.PRODUCTION, + }, + transaction, + }); + assert.equal(await store.resolveTier("id", NOW), "free"); + } +}); + +test("Apple 응답 환경이나 앱 식별자가 다르면 fail-closed 한다", async () => { + const store = createStore({ + response: { + appAppleId: APP_ID, + bundleId: BUNDLE_ID, + data: [], + environment: Environment.SANDBOX, + }, + }); + await assert.rejects(store.resolveTier("id", NOW), (error) => error.status === 503); +}); diff --git a/proxy-vercel/test/auth-lifecycle.test.js b/proxy-vercel/test/auth-lifecycle.test.js new file mode 100644 index 0000000..2a6bf12 --- /dev/null +++ b/proxy-vercel/test/auth-lifecycle.test.js @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createAccountHandler } from "../api/account.js"; +import { createRefreshHandler } from "../api/session-refresh.js"; +import { createLogoutHandler } from "../api/session-logout.js"; +import { encryptSecret, hashToken } from "../lib/security.js"; +import { MemoryStore, appRequest, baseConfig, createResponse } from "./helpers.js"; + +const NOW = new Date("2026-09-22T03:00:00.000Z"); + +test("갱신 토큰은 Apple 상태를 확인하고 한 번 사용한 뒤 회전한다", async () => { + const config = baseConfig(); + const store = new MemoryStore(); + const oldToken = "r".repeat(43); + const subject = "subject-1"; + store.refreshes.set(hashToken(oldToken), { + expiresAt: "2030-01-01T00:00:00.000Z", + subject, + }); + store.accounts.set(subject, { + appleRefreshToken: encryptSecret("apple-refresh", config.authTokenEncryptionKey), + validatedAt: "2026-09-20T00:00:00.000Z", + }); + let validatedToken; + const handler = createRefreshHandler({ + appleSignInFactory: () => ({ + validateRefreshToken: async (token) => { + validatedToken = token; + }, + }), + appleStoreFactory: () => ({}), + clock: () => NOW, + configLoader: () => config, + storeFactory: () => store, + tokenFactory: () => "n".repeat(43), + }); + const res = createResponse(); + + await handler(appRequest({ refreshToken: oldToken }), res); + + assert.equal(res.statusCode, 200); + assert.equal(validatedToken, "apple-refresh"); + assert.equal(store.refreshes.has(hashToken(oldToken)), false); + assert.equal(store.refreshes.has(hashToken("n".repeat(43))), true); +}); + +test("동시에 재사용한 갱신 토큰은 한 요청에만 허용된다", async () => { + const config = baseConfig(); + const store = new MemoryStore(); + const oldToken = "r".repeat(43); + const subject = "subject-1"; + store.refreshes.set(hashToken(oldToken), { + expiresAt: "2030-01-01T00:00:00.000Z", + subject, + }); + store.accounts.set(subject, { + appleRefreshToken: encryptSecret("apple-refresh", config.authTokenEncryptionKey), + validatedAt: NOW.toISOString(), + }); + const handler = createRefreshHandler({ + appleSignInFactory: () => ({}), + appleStoreFactory: () => ({}), + clock: () => NOW, + configLoader: () => config, + storeFactory: () => store, + tokenFactory: () => "n".repeat(43), + }); + const responses = [createResponse(), createResponse()]; + + await Promise.all( + responses.map((response) => handler(appRequest({ refreshToken: oldToken }), response)) + ); + + assert.deepEqual( + responses.map((response) => response.statusCode).sort(), + [200, 401] + ); +}); + +test("계정 삭제는 Apple 토큰을 폐기한 뒤 서버 데이터를 제거한다", async () => { + const config = baseConfig(); + const store = new MemoryStore(); + const sessionToken = "s".repeat(43); + const subject = "subject-1"; + store.installSession(sessionToken, { subject }); + store.accounts.set(subject, { + appleRefreshToken: encryptSecret("apple-refresh", config.authTokenEncryptionKey), + validatedAt: NOW.toISOString(), + }); + let revokedToken; + const handler = createAccountHandler({ + appleSignInFactory: () => ({ + revoke: async (token) => { + revokedToken = token; + }, + }), + clock: () => NOW, + configLoader: () => config, + storeFactory: () => store, + }); + const req = appRequest(undefined, { + headers: { + authorization: `Bearer ${sessionToken}`, + "x-paxo-token": "app-token", + }, + method: "DELETE", + }); + const res = createResponse(); + + await handler(req, res); + + assert.equal(res.statusCode, 204); + assert.equal(revokedToken, "apple-refresh"); + assert.equal(store.accounts.has(subject), false); + assert.equal(store.sessions.size, 0); +}); + +test("로그아웃은 갱신 토큰과 현재 액세스 세션을 함께 폐기한다", async () => { + const config = baseConfig(); + const store = new MemoryStore(); + const refreshToken = "r".repeat(43); + const sessionToken = "s".repeat(43); + store.refreshes.set(hashToken(refreshToken), { + expiresAt: "2030-01-01T00:00:00.000Z", + subject: "subject-1", + }); + store.installSession(sessionToken, { subject: "subject-1" }); + const handler = createLogoutHandler({ + configLoader: () => config, + storeFactory: () => store, + }); + const req = appRequest( + { refreshToken }, + { + headers: { + authorization: `Bearer ${sessionToken}`, + "x-paxo-token": "app-token", + }, + } + ); + const res = createResponse(); + + await handler(req, res); + + assert.equal(res.statusCode, 204); + assert.equal(store.refreshes.has(hashToken(refreshToken)), false); + assert.equal(store.sessions.has(hashToken(sessionToken)), false); +}); diff --git a/proxy-vercel/test/config-security.test.js b/proxy-vercel/test/config-security.test.js new file mode 100644 index 0000000..913528e --- /dev/null +++ b/proxy-vercel/test/config-security.test.js @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { loadConfig } from "../lib/config.js"; +import { HttpError } from "../lib/errors.js"; +import { bearerToken, requireAppToken, seoulUsageWindow } from "../lib/security.js"; +import { baseConfig } from "./helpers.js"; + +function configEnv(overrides = {}) { + return { + APP_APPLE_ID: "1234567890", + APP_BUNDLE_ID: "com.hyeseong.Paxo", + APP_STORE_ENVIRONMENT: "PRODUCTION", + APP_STORE_ISSUER_ID: "issuer", + APP_STORE_KEY_ID: "key", + APP_STORE_PRIVATE_KEY: "private\\nkey", + APP_TOKEN: "app-token", + APPLE_CLIENT_ID: "com.hyeseong.Paxo", + APPLE_SIGN_IN_KEY_ID: "sign-in-key", + APPLE_SIGN_IN_PRIVATE_KEY: "sign-in\\nprivate-key", + APPLE_TEAM_ID: "L3JLLU88WG", + AUTH_TOKEN_ENCRYPTION_KEY: "a".repeat(64), + GEMINI_API_KEY: "gemini-key", + IDENTITY_HASH_SECRET: "i".repeat(32), + MODEL: "gemini-3.6-flash", + REDIS_KEY_PREFIX: "paxo:dev", + UPSTASH_REDIS_REST_TOKEN: "redis-token", + UPSTASH_REDIS_REST_URL: "https://redis.example.com", + ...overrides, + }; +} + +test("필수 환경변수가 없으면 fail-closed 한다", () => { + assert.throws(() => loadConfig({}), (error) => { + assert.equal(error.status, 503); + assert.equal(error.code, "server_misconfigured"); + return true; + }); +}); + +test("허용되지 않은 모델과 잘못된 Production App Apple ID를 거절한다", () => { + assert.throws(() => loadConfig(configEnv({ MODEL: "other-model" })), HttpError); + assert.throws(() => loadConfig(configEnv({ APP_APPLE_ID: "" })), HttpError); + assert.throws(() => loadConfig(configEnv({ APP_BUNDLE_ID: "com.example.Other" })), HttpError); + assert.throws(() => loadConfig(configEnv({ IDENTITY_HASH_SECRET: "short" })), HttpError); +}); + +test("Sandbox는 App Apple ID 없이 설정할 수 있다", () => { + const config = loadConfig(configEnv({ APP_APPLE_ID: "", APP_STORE_ENVIRONMENT: "SANDBOX" })); + assert.equal(config.appAppleId, undefined); + assert.equal(config.appStorePrivateKey, "private\nkey"); +}); + +test("Vercel Preview와 Production은 StoreKit 환경을 섞지 않는다", () => { + assert.throws( + () => loadConfig(configEnv({ APP_STORE_ENVIRONMENT: "SANDBOX", VERCEL_ENV: "production" })), + HttpError + ); + assert.throws( + () => loadConfig(configEnv({ APP_STORE_ENVIRONMENT: "PRODUCTION", VERCEL_ENV: "preview" })), + HttpError + ); + assert.doesNotThrow(() => + loadConfig( + configEnv({ + APP_APPLE_ID: "", + APP_STORE_ENVIRONMENT: "SANDBOX", + REDIS_KEY_PREFIX: "paxo:preview", + VERCEL_ENV: "preview", + }) + ) + ); +}); + +test("Production과 Preview가 같은 Redis를 사용해도 키 영역을 섞지 않는다", () => { + assert.throws( + () => loadConfig(configEnv({ REDIS_KEY_PREFIX: "paxo:preview", VERCEL_ENV: "production" })), + HttpError + ); + assert.throws( + () => + loadConfig( + configEnv({ + APP_APPLE_ID: "", + APP_STORE_ENVIRONMENT: "SANDBOX", + REDIS_KEY_PREFIX: "paxo:prod", + VERCEL_ENV: "preview", + }) + ), + HttpError + ); +}); + +test("앱 토큰과 Bearer 세션을 엄격히 검사한다", () => { + const config = baseConfig(); + assert.doesNotThrow(() => requireAppToken({ headers: { "x-paxo-token": "app-token" } }, config)); + assert.throws(() => requireAppToken({ headers: {} }, config), HttpError); + assert.equal(bearerToken({ headers: { authorization: `Bearer ${"a".repeat(43)}` } }), "a".repeat(43)); + assert.throws(() => bearerToken({ headers: { authorization: "Bearer short" } }), HttpError); +}); + +test("사용량 날짜와 초기화 시각은 Asia/Seoul 자정을 따른다", () => { + const before = seoulUsageWindow(new Date("2026-09-21T14:59:59.000Z")); + const after = seoulUsageWindow(new Date("2026-09-21T15:00:00.000Z")); + assert.equal(before.day, "2026-09-21"); + assert.equal(before.resetAt, "2026-09-21T15:00:00.000Z"); + assert.equal(after.day, "2026-09-22"); +}); diff --git a/proxy-vercel/test/generate.test.js b/proxy-vercel/test/generate.test.js new file mode 100644 index 0000000..1772157 --- /dev/null +++ b/proxy-vercel/test/generate.test.js @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildUpstreamBody, createGenerateHandler } from "../api/generate.js"; +import { MemoryStore, SOLVE_ID, appRequest, baseConfig, createResponse, generateBody } from "./helpers.js"; + +const NOW = new Date("2026-09-22T03:00:00.000Z"); +const SESSION_TOKEN = "s".repeat(43); + +function setup({ fetcher, tier = "free" } = {}) { + const store = new MemoryStore(); + store.installSession(SESSION_TOKEN, { tier }); + const calls = []; + const handler = createGenerateHandler({ + clock: () => NOW, + configLoader: () => baseConfig(), + fetcher: + fetcher || + (async (url, options) => { + calls.push({ options, url }); + return { status: 200, text: async () => '{"candidates":[]}' }; + }), + storeFactory: () => store, + }); + return { calls, handler, store }; +} + +function request(body) { + return appRequest(body, { + headers: { + authorization: `Bearer ${SESSION_TOKEN}`, + "content-length": "100", + "x-paxo-token": "app-token", + }, + }); +} + +test("정답 성공 후 사용량과 헤더를 서버 기준으로 갱신한다", async () => { + const { calls, handler, store } = setup(); + const res = createResponse(); + + await handler(request(generateBody()), res); + + assert.equal(res.statusCode, 200); + assert.equal(res.headers["x-paxo-tier"], "free"); + assert.equal(res.headers["x-paxo-remaining"], "2"); + assert.equal(calls.length, 1); + assert.equal(await store.getUsage("subject-1", "2026-09-22"), 1); + const upstreamBody = JSON.parse(calls[0].options.body); + assert.equal(upstreamBody.generationConfig.maxOutputTokens, 256); +}); + +test("무료 4번째와 Pro 101번째 요청은 Gemini 전에 차단한다", async () => { + for (const [tier, used] of [["free", 3], ["pro", 100]]) { + const { calls, handler, store } = setup({ tier }); + store.usage.set("subject-1:2026-09-22", used); + const res = createResponse(); + await handler(request(generateBody()), res); + assert.equal(res.statusCode, 429); + assert.equal(JSON.parse(res.body).error.code, "daily_limit"); + assert.equal(res.headers["x-paxo-tier"], tier); + assert.equal(res.headers["x-paxo-remaining"], "0"); + assert.equal(calls.length, 0); + } +}); + +test("동시 요청에서도 한도를 넘는 호출은 Gemini에 전달하지 않는다", async () => { + const { calls, handler } = setup(); + const solveIds = [ + "11111111-2222-4333-8444-555555555551", + "11111111-2222-4333-8444-555555555552", + "11111111-2222-4333-8444-555555555553", + "11111111-2222-4333-8444-555555555554", + ]; + const responses = solveIds.map(() => createResponse()); + + await Promise.all( + solveIds.map((solveId, index) => + handler(request(generateBody("answer", solveId)), responses[index]) + ) + ); + + assert.equal(calls.length, 3); + assert.equal(responses.filter((response) => response.statusCode === 200).length, 3); + assert.equal(responses.filter((response) => response.statusCode === 429).length, 1); +}); + +test("Gemini 실패와 타임아웃은 차감하지 않고 예약을 해제한다", async () => { + for (const [fetcher, expectedStatus] of [ + [async () => ({ status: 500, text: async () => "error" }), 502], + [async () => { throw Object.assign(new Error("timeout"), { name: "TimeoutError" }); }, 504], + [async () => { throw new Error("network unavailable"); }, 502], + ]) { + const { handler, store } = setup({ fetcher }); + const res = createResponse(); + await handler(request(generateBody()), res); + assert.equal(res.statusCode, expectedStatus); + assert.equal(await store.getUsage("subject-1", "2026-09-22"), 0); + assert.equal(store.answerReservations.size, 0); + } +}); + +test("해설은 성공한 정답에 한 번만 허용하고 추가 차감하지 않는다", async () => { + const { handler, store } = setup(); + const answer = createResponse(); + await handler(request(generateBody()), answer); + + const explanation = createResponse(); + await handler(request(generateBody("explanation")), explanation); + assert.equal(explanation.statusCode, 200); + assert.equal(explanation.headers["x-paxo-remaining"], "2"); + assert.equal(await store.getUsage("subject-1", "2026-09-22"), 1); + + const duplicate = createResponse(); + await handler(request(generateBody("explanation")), duplicate); + assert.equal(duplicate.statusCode, 409); + assert.equal(JSON.parse(duplicate.body).error.code, "duplicate_request"); +}); + +test("정답 전에 요청한 해설과 만료 세션을 거절한다", async () => { + const { handler, store } = setup(); + const explanation = createResponse(); + await handler(request(generateBody("explanation")), explanation); + assert.equal(explanation.statusCode, 409); + assert.equal(JSON.parse(explanation.body).error.code, "answer_required"); + + store.installSession(SESSION_TOKEN, { expiresAt: "2020-01-01T00:00:00.000Z" }); + const expired = createResponse(); + await handler(request(generateBody()), expired); + assert.equal(expired.statusCode, 401); +}); + +test("subject 버스트 제한과 Redis 장애는 Gemini 전에 차단한다", async () => { + const burst = setup(); + burst.store.burstAllowed = false; + const burstResponse = createResponse(); + await burst.handler(request(generateBody()), burstResponse); + assert.equal(burstResponse.statusCode, 429); + assert.equal(burst.calls.length, 0); + + const unavailable = setup(); + unavailable.store.getSession = async () => { throw new Error("redis unavailable"); }; + const unavailableResponse = createResponse(); + await unavailable.handler(request(generateBody()), unavailableResponse); + assert.equal(unavailableResponse.statusCode, 503); + assert.equal(unavailable.calls.length, 0); +}); + +test("본문 형식, MIME, base64, 메타데이터를 엄격히 검증한다", () => { + assert.equal(buildUpstreamBody(generateBody()).upstream.generationConfig.maxOutputTokens, 256); + assert.equal( + buildUpstreamBody(generateBody("explanation")).upstream.generationConfig.maxOutputTokens, + 1024 + ); + assert.throws(() => buildUpstreamBody({ ...generateBody(), extra: true })); + const badMime = generateBody(); + badMime.contents[0].parts[1].inline_data.mime_type = "image/gif"; + assert.throws(() => buildUpstreamBody(badMime)); + const badBase64 = generateBody(); + badBase64.contents[0].parts[1].inline_data.data = "not_base64"; + assert.throws(() => buildUpstreamBody(badBase64)); + const mismatchedMime = generateBody(); + mismatchedMime.contents[0].parts[1].inline_data.mime_type = "image/png"; + assert.throws(() => buildUpstreamBody(mismatchedMime)); + const extraMetadata = generateBody(); + extraMetadata.contents[0].parts[1].inline_data.display_name = "capture.jpg"; + assert.throws(() => buildUpstreamBody(extraMetadata)); +}); + +test("응답이나 에러에 세션 토큰과 이미지가 노출되지 않는다", async () => { + const { handler } = setup({ + fetcher: async () => ({ status: 500, text: async () => "private upstream body" }), + }); + const res = createResponse(); + await handler(request(generateBody()), res); + assert.equal(res.body.includes(SESSION_TOKEN), false); + assert.equal(res.body.includes("/9j/"), false); + assert.equal(res.body.includes("private upstream body"), false); +}); + +export { SOLVE_ID }; diff --git a/proxy-vercel/test/helpers.js b/proxy-vercel/test/helpers.js new file mode 100644 index 0000000..0dacec8 --- /dev/null +++ b/proxy-vercel/test/helpers.js @@ -0,0 +1,229 @@ +import { hashSessionToken } from "../lib/security.js"; + +const SOLVE_ID = "11111111-2222-4333-8444-555555555555"; + +function createResponse() { + return { + body: "", + headers: {}, + statusCode: 200, + send(body) { + this.body = body; + return this; + }, + setHeader(name, value) { + this.headers[name.toLowerCase()] = String(value); + return this; + }, + status(code) { + this.statusCode = code; + return this; + }, + }; +} + +function baseConfig(overrides = {}) { + return { + appAppleId: 1234567890, + appBundleId: "com.hyeseong.Paxo", + appStoreEnvironment: "PRODUCTION", + appStoreIssuerId: "issuer", + appStoreKeyId: "key", + appStorePrivateKey: "private-key", + appToken: "app-token", + appTokenPrevious: "", + appleClientId: "com.hyeseong.Paxo", + appleSignInKeyId: "sign-in-key", + appleSignInPrivateKey: "sign-in-private-key", + appleTeamId: "L3JLLU88WG", + authTokenEncryptionKey: "a".repeat(64), + geminiApiKey: "gemini-key", + identityHashSecret: "identity-secret", + model: "gemini-3.6-flash", + redisKeyPrefix: "paxo:dev", + redisToken: "redis-token", + redisURL: "https://redis.example.com", + ...overrides, + }; +} + +function appRequest(body, overrides = {}) { + return { + body, + headers: { "content-length": "100", "x-paxo-token": "app-token" }, + method: "POST", + ...overrides, + }; +} + +function generateBody(kind = "answer", solveId = SOLVE_ID) { + return { + contents: [ + { + parts: [ + { text: "문제를 풀어줘" }, + { inline_data: { data: "/9j/", mime_type: "image/jpeg" } }, + ], + }, + ], + kind, + solveId, + }; +} + +class MemoryStore { + constructor() { + this.answers = new Set(); + this.answerReservations = new Set(); + this.explanations = new Set(); + this.explanationReservations = new Set(); + this.sessions = new Map(); + this.tiers = new Map(); + this.usage = new Map(); + this.burstAllowed = true; + this.accounts = new Map(); + this.appTransactions = new Map(); + this.challenges = new Set(); + this.refreshes = new Map(); + } + + async putSession(tokenHash, session) { + this.sessions.set(tokenHash, session); + } + + async getSession(tokenHash) { + return this.sessions.get(tokenHash) || null; + } + + async deleteSession(tokenHash) { + this.sessions.delete(tokenHash); + } + + async putChallenge(nonceHash) { + this.challenges.add(nonceHash); + } + + async consumeChallenge(nonceHash) { + return this.challenges.delete(nonceHash); + } + + async putRefresh(tokenHash, refresh) { + this.refreshes.set(tokenHash, refresh); + } + + async getRefresh(tokenHash) { + return this.refreshes.get(tokenHash) || null; + } + + async consumeRefresh(tokenHash) { + const refresh = this.refreshes.get(tokenHash) || null; + this.refreshes.delete(tokenHash); + return refresh; + } + + async deleteRefresh(tokenHash) { + this.refreshes.delete(tokenHash); + } + + async putAccount(subject, account) { + this.accounts.set(subject, account); + } + + async getAccount(subject) { + return this.accounts.get(subject) || null; + } + + async bindAppTransaction(transactionHash, subject) { + const current = this.appTransactions.get(transactionHash); + if (current && current !== subject) return false; + this.appTransactions.set(transactionHash, subject); + return true; + } + + async deleteSubject(subject) { + this.accounts.delete(subject); + for (const [key, value] of this.sessions) { + if (value.subject === subject) this.sessions.delete(key); + } + for (const [key, value] of this.refreshes) { + if (value.subject === subject) this.refreshes.delete(key); + } + for (const [key, value] of this.appTransactions) { + if (value === subject) this.appTransactions.delete(key); + } + } + + async getTier(subject) { + return this.tiers.get(subject) || null; + } + + async setTier(subject, tier) { + this.tiers.set(subject, tier); + } + + async getUsage(subject, day) { + return this.usage.get(`${subject}:${day}`) || 0; + } + + async checkBurst() { + return this.burstAllowed; + } + + async reserveAnswer(subject, day, solveId, limit) { + const doneKey = `${subject}:${solveId}`; + const pendingKey = `${subject}:${day}:${solveId}`; + if (this.answers.has(doneKey) || this.answerReservations.has(pendingKey)) { + return { allowed: false, reason: "duplicate" }; + } + const used = await this.getUsage(subject, day); + const pending = [...this.answerReservations].filter((key) => key.startsWith(`${subject}:${day}:`)); + if (used + pending.length >= limit) return { allowed: false, reason: "daily_limit" }; + this.answerReservations.add(pendingKey); + return { allowed: true, reason: "ok" }; + } + + async finalizeAnswer(subject, day, solveId, limit) { + const pendingKey = `${subject}:${day}:${solveId}`; + if (!this.answerReservations.delete(pendingKey)) throw new Error("reservation missing"); + const usageKey = `${subject}:${day}`; + const used = (this.usage.get(usageKey) || 0) + 1; + this.usage.set(usageKey, used); + this.answers.add(`${subject}:${solveId}`); + return Math.max(0, limit - used); + } + + async releaseAnswer(subject, day, solveId) { + this.answerReservations.delete(`${subject}:${day}:${solveId}`); + } + + async reserveExplanation(subject, solveId) { + const key = `${subject}:${solveId}`; + if (!this.answers.has(key)) return { allowed: false, reason: "answer_required" }; + if (this.explanations.has(key) || this.explanationReservations.has(key)) { + return { allowed: false, reason: "duplicate" }; + } + this.explanationReservations.add(key); + return { allowed: true, reason: "ok" }; + } + + async finalizeExplanation(subject, solveId) { + const key = `${subject}:${solveId}`; + if (!this.explanationReservations.delete(key)) throw new Error("reservation missing"); + this.explanations.add(key); + } + + async releaseExplanation(subject, solveId) { + this.explanationReservations.delete(`${subject}:${solveId}`); + } + + installSession(token, overrides = {}) { + this.sessions.set(hashSessionToken(token), { + expiresAt: "2030-01-01T00:00:00.000Z", + subject: "subject-1", + tier: "free", + ...overrides, + }); + } +} + +export { MemoryStore, SOLVE_ID, appRequest, baseConfig, createResponse, generateBody }; diff --git a/proxy-vercel/test/session.test.js b/proxy-vercel/test/session.test.js new file mode 100644 index 0000000..6118cec --- /dev/null +++ b/proxy-vercel/test/session.test.js @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChallengeHandler } from "../api/session-challenge.js"; +import { createSessionHandler, parseSessionBody } from "../api/session.js"; +import { hashSubject, hashToken, sha256 } from "../lib/security.js"; +import { MemoryStore, appRequest, baseConfig, createResponse } from "./helpers.js"; + +const NOW = new Date("2026-09-22T03:00:00.000Z"); +const NONCE = "one-time-nonce"; + +function signInBody(overrides = {}) { + return { + proof: { + authorizationCode: "authorization-code", + identityToken: "identity.token.signature", + nonce: NONCE, + type: "appleIdentity", + }, + storeKitProof: { jws: "one.two.three", type: "appTransaction" }, + ...overrides, + }; +} + +test("일회용 nonce와 Apple 로그인 증명으로 15분 세션을 발급한다", async () => { + const store = new MemoryStore(); + await store.putChallenge(sha256(NONCE)); + const handler = createSessionHandler({ + appleSignInFactory: () => ({ + exchangeAuthorizationCode: async () => "apple-refresh-token", + verifyIdentityToken: async () => "apple-user-id", + }), + appleStoreFactory: () => ({ + resolveTier: async () => "pro", + verifyAppTransaction: async () => "app-transaction-id", + }), + clock: () => NOW, + configLoader: () => baseConfig(), + storeFactory: () => store, + tokenFactory: () => "s".repeat(43), + }); + const res = createResponse(); + + await handler(appRequest(signInBody()), res); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.tier, "pro"); + assert.equal(body.remainingToday, 100); + assert.equal(body.expiresAt, "2026-09-22T03:15:00.000Z"); + assert.equal(store.sessions.has(hashToken(body.sessionToken)), true); + assert.equal(store.refreshes.has(hashToken(body.refreshToken)), true); + const subject = hashSubject("apple:apple-user-id", "identity-secret"); + assert.equal(store.accounts.has(subject), true); + assert.equal(JSON.stringify([...store.accounts.values()]).includes("apple-refresh-token"), false); +}); + +test("무료 로그인은 AppTransaction이 없어도 성공한다", async () => { + const store = new MemoryStore(); + await store.putChallenge(sha256(NONCE)); + let storeKitCalls = 0; + const handler = createSessionHandler({ + appleSignInFactory: () => ({ + exchangeAuthorizationCode: async () => "apple-refresh-token", + verifyIdentityToken: async () => "apple-user-id", + }), + appleStoreFactory: () => ({ + resolveTier: async () => { + storeKitCalls += 1; + return "pro"; + }, + verifyAppTransaction: async () => { + storeKitCalls += 1; + return "id"; + }, + }), + clock: () => NOW, + configLoader: () => baseConfig(), + storeFactory: () => store, + tokenFactory: () => "s".repeat(43), + }); + const res = createResponse(); + + await handler(appRequest(signInBody({ storeKitProof: undefined })), res); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).tier, "free"); + assert.equal(storeKitCalls, 0); +}); + +test("nonce는 한 번만 사용할 수 있다", async () => { + const store = new MemoryStore(); + await store.putChallenge(sha256(NONCE)); + const handler = createSessionHandler({ + appleSignInFactory: () => ({ + exchangeAuthorizationCode: async () => "apple-refresh-token", + verifyIdentityToken: async () => "apple-user-id", + }), + appleStoreFactory: () => ({ resolveTier: async () => "free", verifyAppTransaction: async () => "id" }), + configLoader: () => baseConfig(), + storeFactory: () => store, + }); + const first = createResponse(); + const replay = createResponse(); + + await handler(appRequest(signInBody()), first); + await handler(appRequest(signInBody()), replay); + + assert.equal(first.statusCode, 200); + assert.equal(replay.statusCode, 401); + assert.equal(JSON.parse(replay.body).error.code, "invalid_challenge"); +}); + +test("challenge 엔드포인트는 Redis에 해시만 저장한다", async () => { + const store = new MemoryStore(); + const handler = createChallengeHandler({ + configLoader: () => baseConfig(), + storeFactory: () => store, + tokenFactory: () => NONCE, + }); + const res = createResponse(); + + await handler(appRequest({}), res); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).nonce, NONCE); + assert.equal(store.challenges.has(sha256(NONCE)), true); + assert.equal(store.challenges.has(NONCE), false); +}); + +test("Apple 로그인 증명에 추가 필드를 허용하지 않는다", () => { + assert.equal(parseSessionBody(signInBody()).proof.type, "appleIdentity"); + assert.throws(() => parseSessionBody({ ...signInBody(), extra: true })); + assert.throws(() => parseSessionBody(signInBody({ proof: { type: "appleIdentity" } }))); + assert.throws(() => + parseSessionBody( + signInBody({ proof: { ...signInBody().proof, type: "appTransaction" } }) + ) + ); +}); diff --git a/proxy-vercel/vercel.json b/proxy-vercel/vercel.json index 1e6d317..0e80b3c 100644 --- a/proxy-vercel/vercel.json +++ b/proxy-vercel/vercel.json @@ -1,10 +1,15 @@ { "regions": ["iad1"], "functions": { - "api/*.js": { "maxDuration": 60 } + "api/*.js": { "includeFiles": "certs/**", "maxDuration": 60 } }, "rewrites": [ { "source": "/generate", "destination": "/api/generate" }, + { "source": "/session", "destination": "/api/session" }, + { "source": "/session/challenge", "destination": "/api/session-challenge" }, + { "source": "/session/refresh", "destination": "/api/session-refresh" }, + { "source": "/session/logout", "destination": "/api/session-logout" }, + { "source": "/account", "destination": "/api/account" }, { "source": "/privacy", "destination": "/api/privacy" } ] } diff --git a/proxy/README.md b/proxy/README.md deleted file mode 100644 index eddefe8..0000000 --- a/proxy/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Paxo 프록시 (Cloudflare Worker) — 백업 - -> 현재 기본 프록시는 Vercel(`proxy-vercel/`)이다. Cloudflare Workers는 아웃바운드 IP가 -> 간헐적으로 Gemini 미지원 지역을 경유해 "User location is not supported" 오류가 랜덤 발생하는 -> 문제가 있어(실측 ~50% 실패) 백업으로 강등됐다. 앱의 기본 URL은 `paxo-proxy.vercel.app`이다. - -앱 배포본에 Gemini API 키를 넣지 않기 위한 최소 프록시. 무료 플랜(일 10만 요청)으로 충분하다. - -## 배포 (최초 1회, 약 5분) - -```bash -cd proxy - -# 1. Cloudflare 계정 인증 (브라우저 열림, 계정 없으면 무료 가입) -npx wrangler login - -# 2. Gemini API 키를 서버 시크릿으로 등록 (새로 발급한 키 사용!) -npx wrangler secret put GEMINI_API_KEY - -# 3. 배포 -npx wrangler deploy -``` - -배포가 끝나면 `https://paxo-proxy.<계정명>.workers.dev` 주소가 출력된다. -→ **Paxo 설정 → AI 연결 → 프록시 URL**에 붙여넣으면 끝. (API 키 필드는 비워도 됨) - -이후 코드 수정 시엔 `npx wrangler deploy`만 다시 실행. - -## 선택 옵션 - -**기기별 일일 사용량 제한** (무료 티어/어뷰징 대비): - -```bash -npx wrangler kv namespace create USAGE -``` - -출력된 `id`를 `wrangler.toml`의 `[[kv_namespaces]]` 주석을 해제하고 붙여넣은 뒤 재배포. -한도는 `DAILY_LIMIT`(기본 200회/일)로 조정. 초과 시 앱에 "요청 한도 초과" 메시지가 뜬다. - -**앱 토큰** (간단한 접근 제한): - -```bash -npx wrangler secret put APP_TOKEN -``` - -설정하면 워커가 `x-paxo-token` 헤더를 요구한다. 앱에 같은 값을 보내는 코드를 추가해야 하므로, -StoreKit 구독을 붙일 때 영수증 검증으로 함께 처리하는 것을 권장. - -**모델 교체**: `wrangler.toml`의 `[vars] MODEL` 수정 후 재배포. 앱 수정 불필요. - -## 동작 방식 - -``` -Paxo 앱 ──POST /generate (Gemini 형식 그대로)──▶ Worker ──키 첨부──▶ Gemini API - │ - ├─ (선택) 기기별 일일 한도 (KV) - └─ (선택) 앱 토큰 검사 -``` - -응답도 Gemini 형식 그대로 반환하므로 앱의 파싱 코드는 직접 호출과 동일하다. diff --git a/proxy/worker.js b/proxy/worker.js deleted file mode 100644 index 15288d9..0000000 --- a/proxy/worker.js +++ /dev/null @@ -1,154 +0,0 @@ -// Paxo API 프록시 — Gemini API 키를 앱 밖(서버)에만 둔다. -// 배포 방법: proxy/README.md 참고. -// -// 앱 → POST {프록시URL}/generate (본문은 Gemini generateContent 형식 그대로) -// → 이 워커가 키를 붙여 Google로 전달 → 응답을 그대로 반환. - -const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models"; - -export default { - async fetch(request, env) { - const url = new URL(request.url); - - // 개인정보 처리방침 - if (request.method === "GET" && url.pathname === "/privacy") { - return new Response(PRIVACY_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - } - - if (request.method !== "POST") { - return json({ error: "method not allowed" }, 405); - } - if (url.pathname !== "/generate") { - return json({ error: "not found" }, 404); - } - - if (env.APP_TOKEN) { - const token = request.headers.get("x-paxo-token"); - if (token !== env.APP_TOKEN) { - return json({ error: "unauthorized" }, 401); - } - } - - // 요청 크기 제한 (base64 이미지 포함 여유분) - const length = Number(request.headers.get("content-length") || "0"); - if (length > 12_000_000) { - return json({ error: "payload too large" }, 413); - } - - // 선택: KV 바인딩(USAGE)이 있으면 기기별 일일 한도 적용 - const device = request.headers.get("x-paxo-device") || "unknown"; - if (env.USAGE) { - const limit = Number(env.DAILY_LIMIT || "200"); - const day = new Date().toISOString().slice(0, 10); - const key = `usage:${device}:${day}`; - const used = Number((await env.USAGE.get(key)) || "0"); - if (used >= limit) { - return json({ error: "daily limit exceeded" }, 429); - } - await env.USAGE.put(key, String(used + 1), { - expirationTtl: 60 * 60 * 48, - }); - } - - const model = env.MODEL || "gemini-2.5-flash"; - // 재시도를 위해 본문을 버퍼링 (스트림은 1회만 읽을 수 있음) - const payload = await request.arrayBuffer(); - - // Cloudflare 엣지 아웃바운드 IP가 Gemini 미지원 지역을 경유하면 400이 난다. - // 그 오류일 때만 최대 3회 재시도. (근본 대응은 wrangler.toml의 Smart Placement) - let lastLocationError = null; - for (let attempt = 0; attempt < 3; attempt++) { - const upstream = await fetch(`${GEMINI_BASE}/${model}:generateContent`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-goog-api-key": env.GEMINI_API_KEY, - }, - body: payload, - }); - - if (upstream.status === 400) { - const text = await upstream.text(); - if (text.includes("location is not supported")) { - lastLocationError = text; - continue; - } - return new Response(text, { - status: 400, - headers: { "content-type": "application/json" }, - }); - } - - return new Response(upstream.body, { - status: upstream.status, - headers: { "content-type": "application/json" }, - }); - } - - return new Response(lastLocationError, { - status: 503, - headers: { "content-type": "application/json" }, - }); - }, -}; - -function json(obj, status) { - return new Response(JSON.stringify(obj), { - status, - headers: { "content-type": "application/json" }, - }); -} - -const PRIVACY_HTML = ` - - - - -Paxo 개인정보 처리방침 - - - -

Paxo 개인정보 처리방침

-

시행일: 2026년 7월 21일

- -

수집·처리하는 정보

-
    -
  • 캡처 이미지: 사용자가 단축키로 직접 캡처한 화면 이미지는 AI 풀이 생성을 위해 - 서버로 전송됩니다. 이미지는 풀이 생성에만 사용되며, Paxo 서버에 저장되지 않고 - 처리 즉시 폐기됩니다.
  • -
  • 익명 기기 식별자: 무작위 생성된 식별자를 사용량 제한(어뷰징 방지) 목적으로만 - 사용합니다. 개인을 식별할 수 없으며 다른 정보와 연결되지 않습니다.
  • -
  • Paxo는 계정 가입을 요구하지 않으며 이름, 이메일 등 개인정보를 수집하지 않습니다.
  • -
- -

제3자 처리

-

AI 풀이 생성을 위해 캡처 이미지가 Google Gemini API로 전달됩니다. -Google의 데이터 처리에 관한 내용은 -Google Gemini API 약관을 참고하세요.

- -

결제

-

구독 결제는 Apple App Store를 통해 처리되며, Paxo는 결제 정보에 접근하지 않습니다.

- -

로컬 저장 데이터

-

풀이 기록(정답·해설 텍스트)은 사용자의 기기에만 저장되며 서버로 전송되지 않습니다.

- -

문의

-

개인정보 관련 문의: kimsonghansung@gmail.com

- -
-

Privacy Policy (English Summary)

-

Paxo does not require an account and collects no personal information. -Screen captures you explicitly take are sent to our server solely to generate AI explanations, -forwarded to the Google Gemini API, and are never stored — they are discarded immediately after processing. -A randomly generated anonymous device identifier is used only for rate limiting. -Solve history is stored locally on your device only. Payments are handled by the Apple App Store. -Contact: kimsonghansung@gmail.com

- -`; diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml deleted file mode 100644 index b529315..0000000 --- a/proxy/wrangler.toml +++ /dev/null @@ -1,20 +0,0 @@ -name = "paxo-proxy" -main = "worker.js" -compatibility_date = "2026-07-01" - -# 워커를 Gemini API(미국)와 가까운 위치에서 실행해 -# "User location is not supported" 지역 차단을 방지 -[placement] -mode = "smart" - -# 모델 변경이나 일일 한도 조정 시 주석 해제 -# [vars] -# MODEL = "gemini-2.5-flash" -# DAILY_LIMIT = "200" - -# 기기별 일일 한도를 쓰려면: -# npx wrangler kv namespace create USAGE -# 실행 후 출력된 id를 아래에 붙여넣고 주석 해제 -# [[kv_namespaces]] -# binding = "USAGE" -# id = "여기에-생성된-id" From 772d40f85eb63934afbf117cb0bd443d9059fe86 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 28 Sep 2026 23:38:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B5=AC=EB=8F=85=20=EC=B2=B4?= =?UTF-8?q?=ED=97=98=EA=B3=BC=20Preview=20=EC=84=A4=EC=A0=95=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreKit 무료 체험을 3일로 맞추고 한국어·영어 상품 설명을 추가 - 실제 최초 혜택 대상에게만 무료 체험을 표시 - Debug 빌드 기본 프록시를 Preview 도메인으로 분리 --- Config/Paxo.storekit | 22 ++++++++++----- Paxo/AI/GeminiService.swift | 2 +- Paxo/AI/ProxySessionManager.swift | 2 +- Paxo/AppState.swift | 3 ++- Paxo/DefaultConfig.swift | 5 ++++ Paxo/Store/StoreManager.swift | 20 ++++++++++++++ Paxo/UI/PaywallView.swift | 34 +++++++++++++++++++++--- Paxo/UI/SettingsView.swift | 4 +-- PaxoTests/GeminiServiceTests.swift | 13 +++++++++ PaxoTests/ProxySessionManagerTests.swift | 2 +- 10 files changed, 91 insertions(+), 16 deletions(-) diff --git a/Config/Paxo.storekit b/Config/Paxo.storekit index c1c460e..e2390dd 100644 --- a/Config/Paxo.storekit +++ b/Config/Paxo.storekit @@ -31,13 +31,18 @@ "introductoryOffer" : { "internalID" : "A1B2C3D4E5F60011", "paymentMode" : "free", - "subscriptionPeriod" : "P1W" + "subscriptionPeriod" : "P3D" }, "localizations" : [ { - "description" : "풀이와 해설 무제한", - "displayName" : "Paxo Pro 월간", + "description" : "Up to 100 AI solutions and explanations per day", + "displayName" : "Paxo Pro Monthly", "locale" : "en_US" + }, + { + "description" : "하루 최대 100회 AI 풀이 및 해설", + "displayName" : "Paxo Pro 월간", + "locale" : "ko_KR" } ], "productID" : "com.hyeseong.Paxo.pro.monthly", @@ -60,13 +65,18 @@ "introductoryOffer" : { "internalID" : "A1B2C3D4E5F60012", "paymentMode" : "free", - "subscriptionPeriod" : "P1W" + "subscriptionPeriod" : "P3D" }, "localizations" : [ { - "description" : "풀이와 해설 무제한 (연간, 약 33% 할인)", - "displayName" : "Paxo Pro 연간", + "description" : "Up to 100 AI solutions and explanations per day", + "displayName" : "Paxo Pro Yearly", "locale" : "en_US" + }, + { + "description" : "하루 최대 100회 AI 풀이 및 해설", + "displayName" : "Paxo Pro 연간", + "locale" : "ko_KR" } ], "productID" : "com.hyeseong.Paxo.pro.yearly", diff --git a/Paxo/AI/GeminiService.swift b/Paxo/AI/GeminiService.swift index c0e69a5..0c2cf2f 100644 --- a/Paxo/AI/GeminiService.swift +++ b/Paxo/AI/GeminiService.swift @@ -61,7 +61,7 @@ struct GeminiService { return try makeDirectRequest() } let candidate = proxyURL.trimmingCharacters(in: .whitespacesAndNewlines) - let proxy = candidate.isEmpty ? DefaultConfig.proxyURL : candidate + let proxy = candidate.isEmpty ? DefaultConfig.developmentProxyURL : candidate #else let proxy = DefaultConfig.proxyURL #endif diff --git a/Paxo/AI/ProxySessionManager.swift b/Paxo/AI/ProxySessionManager.swift index 53fb82f..77d26bc 100644 --- a/Paxo/AI/ProxySessionManager.swift +++ b/Paxo/AI/ProxySessionManager.swift @@ -294,7 +294,7 @@ final class ProxySessionManager { private func makeRequest(path: String) throws -> URLRequest { #if DEBUG let candidate = developmentProxyURL.trimmingCharacters(in: .whitespacesAndNewlines) - let baseURL = candidate.isEmpty ? DefaultConfig.proxyURL : candidate + let baseURL = candidate.isEmpty ? DefaultConfig.developmentProxyURL : candidate #else let baseURL = DefaultConfig.proxyURL #endif diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index f23a9c2..750bffc 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -161,7 +161,8 @@ final class AppState: ObservableObject { hotkey = .default } #if DEBUG - proxyURL = UserDefaults.standard.string(forKey: "proxyURL") ?? "" + proxyURL = + UserDefaults.standard.string(forKey: "proxyURL") ?? DefaultConfig.developmentProxyURL apiKey = KeychainHelper.load(key: "gemini-api-key") ?? "" useDirectGemini = UserDefaults.standard.bool(forKey: "useDirectGemini") #endif diff --git a/Paxo/DefaultConfig.swift b/Paxo/DefaultConfig.swift index 5ca05c7..5d2f77a 100644 --- a/Paxo/DefaultConfig.swift +++ b/Paxo/DefaultConfig.swift @@ -6,6 +6,11 @@ enum DefaultConfig { /// Release 빌드는 이 주소만 사용하며 사용자 설정으로 바꿀 수 없다. static let proxyURL = "https://api.paxo.co.kr" + #if DEBUG + /// 개발 빌드는 운영 데이터와 분리된 Preview 프록시를 기본으로 사용한다. + static let developmentProxyURL = "https://preview-api.paxo.co.kr" + #endif + /// 개인정보 처리방침 (프록시 워커가 /privacy 경로로 서빙) static let privacyPolicyURL = proxyURL + "/privacy" diff --git a/Paxo/Store/StoreManager.swift b/Paxo/Store/StoreManager.swift index fd02f62..55ad396 100644 --- a/Paxo/Store/StoreManager.swift +++ b/Paxo/Store/StoreManager.swift @@ -14,6 +14,7 @@ enum StoreProducts { /// - 출시 전: App Store Connect에 동일한 product ID로 구독 상품 생성 필요 @MainActor final class StoreManager: ObservableObject { + @Published private(set) var eligibleIntroOfferProductIDs: Set = [] @Published private(set) var products: [Product] = [] @Published private(set) var isPro = false @Published private(set) var purchaseInFlight = false @@ -43,12 +44,18 @@ final class StoreManager: ObservableObject { do { products = try await Product.products(for: StoreProducts.identifiers) .sorted { $0.price < $1.price } + await refreshIntroOfferEligibility() } catch { + eligibleIntroOfferProductIDs = [] products = [] errorMessage = "가격 정보를 불러오지 못했습니다: \(error.localizedDescription)" } } + func isEligibleForIntroOffer(_ product: Product) -> Bool { + eligibleIntroOfferProductIDs.contains(product.id) + } + func refreshEntitlements() async { let previous = isPro var pro = false @@ -61,6 +68,7 @@ final class StoreManager: ObservableObject { } } isPro = pro + await refreshIntroOfferEligibility() if hasLoadedEntitlements, previous != pro { await onEntitlementsChanged?() } @@ -106,4 +114,16 @@ final class StoreManager: ObservableObject { } await refreshEntitlements() } + + private func refreshIntroOfferEligibility() async { + var eligibleProductIDs: Set = [] + for product in products { + guard let subscription = product.subscription, + subscription.introductoryOffer?.paymentMode == .freeTrial, + await subscription.isEligibleForIntroOffer + else { continue } + eligibleProductIDs.insert(product.id) + } + eligibleIntroOfferProductIDs = eligibleProductIDs + } } diff --git a/Paxo/UI/PaywallView.swift b/Paxo/UI/PaywallView.swift index f2bd8eb..28adb2f 100644 --- a/Paxo/UI/PaywallView.swift +++ b/Paxo/UI/PaywallView.swift @@ -47,8 +47,8 @@ struct PaywallView: View { Text("\(product.displayPrice) \(periodLabel(product))") .font(.caption) .foregroundStyle(.secondary) - if hasFreeTrial(product) { - Text("7일 무료 체험 포함") + if let freeTrialText = freeTrialText(product) { + Text(freeTrialText) .font(.caption2) .foregroundStyle(.tint) } @@ -60,6 +60,13 @@ struct PaywallView: View { } } .disabled(store.purchaseInFlight) + + if showsIntroOfferNotice { + Text("무료 체험은 신규 구독자에게 같은 구독 그룹에서 한 번만 제공됩니다.") + .font(.caption2) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } } } @@ -107,7 +114,26 @@ struct PaywallView: View { } } - private func hasFreeTrial(_ product: Product) -> Bool { - product.subscription?.introductoryOffer?.paymentMode == .freeTrial + private var showsIntroOfferNotice: Bool { + store.products.contains { store.isEligibleForIntroOffer($0) } + } + + private func freeTrialText(_ product: Product) -> String? { + guard store.isEligibleForIntroOffer(product), + let offer = product.subscription?.introductoryOffer, + offer.paymentMode == .freeTrial, + let unit = periodUnitLabel(offer.period.unit) + else { return nil } + return "\(offer.period.value)\(unit) 무료 체험 포함" + } + + private func periodUnitLabel(_ unit: Product.SubscriptionPeriod.Unit) -> String? { + switch unit { + case .day: return "일" + case .week: return "주" + case .month: return "개월" + case .year: return "년" + @unknown default: return nil + } } } diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 2511088..896a6a0 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -102,9 +102,9 @@ struct SettingsView: View { TextField( "프록시 URL", text: $appState.proxyURL, - prompt: Text(DefaultConfig.proxyURL) + prompt: Text(DefaultConfig.developmentProxyURL) ) - Text("비워두면 내장 기본 프록시(DefaultConfig)를 사용합니다.") + Text("비워두면 Preview 프록시를 사용합니다.") .font(.caption) .foregroundStyle(.secondary) diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift index 14570ab..e35f41c 100644 --- a/PaxoTests/GeminiServiceTests.swift +++ b/PaxoTests/GeminiServiceTests.swift @@ -22,6 +22,19 @@ struct GeminiServiceTests { } #if DEBUG + @Test("개발 프록시 주소가 비어 있으면 Preview를 사용한다") + func emptyDevelopmentProxyUsesPreview() throws { + let service = GeminiService( + apiKey: "", + proxyURL: "", + useDirectGemini: false + ) + + let request = try service.makeRequest(sessionToken: "session-token") + + #expect(request.url?.absoluteString == "https://preview-api.paxo.co.kr/generate") + } + @Test("개발용 직접 호출은 프록시를 우회한다") func directRequestUsesGeminiKey() throws { let service = GeminiService( diff --git a/PaxoTests/ProxySessionManagerTests.swift b/PaxoTests/ProxySessionManagerTests.swift index 107a835..ca52508 100644 --- a/PaxoTests/ProxySessionManagerTests.swift +++ b/PaxoTests/ProxySessionManagerTests.swift @@ -12,7 +12,7 @@ struct ProxySessionManagerTests { if request.url?.path == "/session/challenge" { return try response(request: request, status: 200, body: #"{"nonce":"raw-nonce"}"#) } - #expect(request.url?.absoluteString == "https://api.paxo.co.kr/session") + #expect(request.url?.absoluteString == "https://preview-api.paxo.co.kr/session") #expect(request.value(forHTTPHeaderField: "x-paxo-token") != nil) let requestBody = try #require(request.httpBody) let decoded = try JSONSerialization.jsonObject(with: requestBody)