diff --git a/bun.lock b/bun.lock index 0c08ccf..9c1e4d9 100644 --- a/bun.lock +++ b/bun.lock @@ -13,9 +13,10 @@ "name": "@maudecode/cowtail-protocol", "version": "0.1.3", "dependencies": { - "zod": "^3.25.76", + "zod": "^4.1.12", }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "^8.1.0", "typescript": "^5.9.3", }, }, @@ -53,6 +54,8 @@ }, }, "packages": { + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.5.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -553,6 +556,8 @@ "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "oxfmt": ["oxfmt@0.45.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.45.0", "@oxfmt/binding-android-arm64": "0.45.0", "@oxfmt/binding-darwin-arm64": "0.45.0", "@oxfmt/binding-darwin-x64": "0.45.0", "@oxfmt/binding-freebsd-x64": "0.45.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", "@oxfmt/binding-linux-arm64-gnu": "0.45.0", "@oxfmt/binding-linux-arm64-musl": "0.45.0", "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-musl": "0.45.0", "@oxfmt/binding-linux-s390x-gnu": "0.45.0", "@oxfmt/binding-linux-x64-gnu": "0.45.0", "@oxfmt/binding-linux-x64-musl": "0.45.0", "@oxfmt/binding-openharmony-arm64": "0.45.0", "@oxfmt/binding-win32-arm64-msvc": "0.45.0", "@oxfmt/binding-win32-ia32-msvc": "0.45.0", "@oxfmt/binding-win32-x64-msvc": "0.45.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg=="], @@ -643,9 +648,11 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], @@ -673,8 +680,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], diff --git a/cli/bun.lock b/cli/bun.lock index 3917fa8..d04c535 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -18,7 +18,7 @@ "@maudecode/cowtail-protocol", ], "packages": { - "@maudecode/cowtail-protocol": ["@maudecode/cowtail-protocol@file:../protocol", { "dependencies": { "zod": "^3.25.76" }, "devDependencies": { "typescript": "^5.9.3" } }], + "@maudecode/cowtail-protocol": ["@maudecode/cowtail-protocol@file:../protocol", { "dependencies": { "zod": "^4.1.12" }, "devDependencies": { "typescript": "^5.9.3" } }], "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], @@ -30,6 +30,6 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], } } diff --git a/ios/.gitignore b/ios/.gitignore index a2ab730..99a6293 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -14,6 +14,8 @@ Cowtail.xcodeproj/ .build/ Packages/ Package.resolved +OpenAPITools/Sources/CowtailGeneratedAPI/GeneratedSources/ +OpenAPITools/Sources/CowtailGeneratedAPI/openapi.json # Fastlane fastlane/report.xml diff --git a/ios/CowtailApp/Resources/Info.plist b/ios/CowtailApp/Resources/Info.plist new file mode 100644 index 0000000..074a38f --- /dev/null +++ b/ios/CowtailApp/Resources/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Cowtail + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + CowtailAlertWriteURL + $(COWTAIL_ALERT_WRITE_URL) + CowtailConvexQueryURL + $(COWTAIL_CONVEX_QUERY_URL) + CowtailHealthSummaryURL + $(COWTAIL_HEALTH_SUMMARY_URL) + CowtailPublicSiteURL + $(COWTAIL_PUBLIC_SITE_URL) + CowtailPushRegistrationURL + $(COWTAIL_PUSH_REGISTRATION_URL) + CowtailPushUnregistrationURL + $(COWTAIL_PUSH_UNREGISTRATION_URL) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + + diff --git a/ios/CowtailApp/Sources/App/AppConfig.swift b/ios/CowtailApp/Sources/App/AppConfig.swift index d2cfca2..7998e5d 100644 --- a/ios/CowtailApp/Sources/App/AppConfig.swift +++ b/ios/CowtailApp/Sources/App/AppConfig.swift @@ -22,6 +22,25 @@ enum AppConfig { return URL(string: string) } + static func baseURL(for endpointURL: URL, droppingLastPathComponents count: Int) -> URL { + precondition(count >= 0, "Path component count must be nonnegative") + + var result = endpointURL + for _ in 0.. 1, components.path.hasSuffix("/") { + components.path.removeLast() + } + + return components.url ?? result + } + private static func requiredURL(_ key: String) -> URL { guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, diff --git a/ios/CowtailApp/Sources/App/CowtailStore.swift b/ios/CowtailApp/Sources/App/CowtailStore.swift index b387a6c..09e499d 100644 --- a/ios/CowtailApp/Sources/App/CowtailStore.swift +++ b/ios/CowtailApp/Sources/App/CowtailStore.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog @MainActor final class CowtailStore: ObservableObject { @@ -14,6 +15,10 @@ final class CowtailStore: ObservableObject { @Published var errorMessage: String? private let api: CowtailAPI + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Cowtail", + category: "store" + ) private var hasLoaded = false init( @@ -64,6 +69,7 @@ final class CowtailStore: ObservableObject { didLoadAlerts = true } catch { guard !isCancellation(error) else { return } + logger.error("refresh alerts failed: \(String(describing: error), privacy: .public)") errorMessage = error.localizedDescription } @@ -71,6 +77,7 @@ final class CowtailStore: ObservableObject { health = try await healthTask } catch { guard !isCancellation(error) else { return } + logger.error("refresh health failed: \(String(describing: error), privacy: .public)") healthErrorMessage = error.localizedDescription } @@ -89,6 +96,7 @@ final class CowtailStore: ObservableObject { fixesByAlertID[alertID] = try await api.fetchFixes(alertIDs: [alertID]) } catch { guard !isCancellation(error) else { return } + logger.error("loadFixes failed for \(alertID, privacy: .public): \(String(describing: error), privacy: .public)") errorMessage = error.localizedDescription } } @@ -125,6 +133,7 @@ final class CowtailStore: ObservableObject { alertCacheByID[alert.id] = alert } catch { guard !isCancellation(error) else { return } + logger.error("loadAlert failed for \(alertID, privacy: .public): \(String(describing: error), privacy: .public)") alertLoadErrors[alertID] = error.localizedDescription } } diff --git a/ios/CowtailApp/Sources/Networking/CowtailAPI.swift b/ios/CowtailApp/Sources/Networking/CowtailAPI.swift index c483e79..98afb65 100644 --- a/ios/CowtailApp/Sources/Networking/CowtailAPI.swift +++ b/ios/CowtailApp/Sources/Networking/CowtailAPI.swift @@ -1,69 +1,177 @@ import Foundation +import OpenAPIRuntime +import OpenAPIURLSession +import OSLog actor CowtailAPI { - private let decoder = JSONDecoder() - private let convexQueryURL = AppConfig.convexQueryURL - private let healthSummaryURL = AppConfig.healthSummaryURL - private let pushRegistrationURL = AppConfig.pushRegistrationURL - private let pushUnregistrationURL = AppConfig.pushUnregistrationURL + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Cowtail", + category: "network" + ) + private let transport = URLSessionTransport() + + private var queryClient: Client { + Client( + serverURL: AppConfig.baseURL(for: AppConfig.convexQueryURL, droppingLastPathComponents: 1), + transport: transport + ) + } + + private var healthClient: Client { + Client( + serverURL: AppConfig.baseURL(for: AppConfig.healthSummaryURL, droppingLastPathComponents: 1), + transport: transport + ) + } + + private var pushRegistrationClient: Client { + Client( + serverURL: AppConfig.baseURL(for: AppConfig.pushRegistrationURL, droppingLastPathComponents: 2), + transport: transport + ) + } + + private var pushUnregistrationClient: Client { + Client( + serverURL: AppConfig.baseURL(for: AppConfig.pushUnregistrationURL, droppingLastPathComponents: 2), + transport: transport + ) + } func fetchAlerts(from: Date, to: Date) async throws -> [AlertItem] { - let alerts: [AlertDTO] = try await convexQuery( - path: "alerts:getByTimeRange", - args: [ - "from": Int(from.timeIntervalSince1970 * 1000), - "to": Int(to.timeIntervalSince1970 * 1000) - ] + logger.info("fetchAlerts baseURL=\(AppConfig.baseURL(for: AppConfig.convexQueryURL, droppingLastPathComponents: 1).absoluteString, privacy: .public)") + let output = try await queryClient.query( + body: .json( + .init( + value1: .init( + path: .alerts_colon_getByTimeRange, + args: .init( + from: milliseconds(from), + to: milliseconds(to) + ), + format: .convexEncodedJson + ) + ) + ) ) - return alerts - .map { $0.asAlertItem } - .sorted { $0.timestamp > $1.timestamp } + switch output { + case let .ok(response): + let payload = try response.body.json + guard let envelope = payload.value1 else { + throw CowtailAPIError.requestFailed("Query returned the wrong response shape for alerts.") + } + + switch envelope.status { + case .success: + return (envelope.value ?? []) + .map(makeAlertItem) + .sorted { $0.timestamp > $1.timestamp } + case .error: + throw CowtailAPIError.requestFailed(envelope.errorMessage ?? "Unknown Convex error") + } + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "query.fetchAlerts", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Alert list request failed with status \(statusCode): \(body)") + } } func fetchAlert(id: String) async throws -> AlertItem? { - do { - let alert: AlertDTO? = try await convexQuery( - path: "alerts:getById", - args: ["id": id] + let output = try await queryClient.query( + body: .json( + .init( + value2: .init( + path: .alerts_colon_getById, + args: .init(id: id), + format: .convexEncodedJson + ) + ) ) + ) - return alert?.asAlertItem - } catch let error as CowtailAPIError { - guard case .requestFailed(let message) = error, - message.contains("Could not find public function for 'alerts:getById'") else { - throw error + switch output { + case let .ok(response): + let payload = try response.body.json + guard let envelope = payload.value2 else { + throw CowtailAPIError.requestFailed("Query returned the wrong response shape for alert fetch.") } - let now = Date() - let fallbackStart = Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now.addingTimeInterval(-30 * 24 * 60 * 60) - let recentAlerts = try await fetchAlerts(from: fallbackStart, to: now) - return recentAlerts.first(where: { $0.id == id }) + switch envelope.status { + case .success: + return envelope.value?.value1.map(makeAlertItem) + case .error: + throw CowtailAPIError.requestFailed(envelope.errorMessage ?? "Unknown Convex error") + } + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "query.fetchAlert", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Alert fetch failed with status \(statusCode): \(body)") } } func fetchFixes(alertIDs: [String]) async throws -> [AlertFix] { - let fixes: [FixDTO] = try await convexQuery( - path: "fixes:getByAlertIds", - args: ["alertIds": alertIDs] + let output = try await queryClient.query( + body: .json( + .init( + value3: .init( + path: .fixes_colon_getByAlertIds, + args: .init(alertIds: alertIDs), + format: .convexEncodedJson + ) + ) + ) ) - return fixes - .map { $0.asAlertFix } - .sorted { $0.timestamp > $1.timestamp } + switch output { + case let .ok(response): + let payload = try response.body.json + guard let envelope = payload.value3 else { + throw CowtailAPIError.requestFailed("Query returned the wrong response shape for fixes.") + } + + switch envelope.status { + case .success: + return (envelope.value ?? []) + .map(makeAlertFix) + .sorted { $0.timestamp > $1.timestamp } + case .error: + throw CowtailAPIError.requestFailed(envelope.errorMessage ?? "Unknown Convex error") + } + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "query.fetchFixes", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Fix list request failed with status \(statusCode): \(body)") + } } func fetchHealthSummary() async throws -> HealthSummary { - let (data, response) = try await URLSession.shared.data(from: healthSummaryURL) - try validate(response: response, data: data) - - let decoded = try decode( - HealthSummaryDTO.self, - from: data, - context: "Health response" - ) - - return decoded.asHealthSummary + let output = try await healthClient.fetchHealthSummary() + + switch output { + case let .ok(response): + return makeHealthSummary(try response.body.json) + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "fetchHealthSummary", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Health request failed with status \(statusCode): \(body)") + } } func registerPushDevice( @@ -72,111 +180,124 @@ actor CowtailAPI { environment: String, deviceName: String ) async throws -> PushRegistrationResponse { - try await jsonRequest( - url: pushRegistrationURL, - body: [ - "identityToken": identityToken, - "deviceToken": deviceToken, - "platform": "ios", - "environment": environment, - "deviceName": deviceName - ] + let output = try await pushRegistrationClient.registerPushDevice( + body: .json( + .init( + identityToken: identityToken, + deviceToken: deviceToken, + platform: "ios", + environment: environment, + deviceName: deviceName + ) + ) ) + + switch output { + case let .ok(response): + return try response.body.json + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "registerPushDevice", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Push registration failed with status \(statusCode): \(body)") + } } func unregisterPushDevice(deviceToken: String) async throws -> PushUnregistrationResponse { - try await jsonRequest( - url: pushUnregistrationURL, - body: [ - "deviceToken": deviceToken - ] + let output = try await pushUnregistrationClient.unregisterPushDevice( + body: .json(.init(deviceToken: deviceToken)) ) - } - - private func convexQuery(path: String, args: [String: Any]) async throws -> T { - var request = URLRequest(url: convexQueryURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: [ - "path": path, - "args": args, - "format": "convex_encoded_json" - ]) - let (data, response) = try await URLSession.shared.data(for: request) - try validate(response: response, data: data) - - let envelope: ConvexEnvelope = try decode( - ConvexEnvelope.self, - from: data, - context: "Convex response for \(path)" - ) - if envelope.status == "success", let value = envelope.value { - return value + switch output { + case let .ok(response): + return try response.body.json + case let .undocumented(statusCode, payload): + await logUndocumentedResponse( + operation: "unregisterPushDevice", + statusCode: statusCode, + payload: payload + ) + let body = await bodyString(from: payload.body) + throw CowtailAPIError.requestFailed("Push unregistration failed with status \(statusCode): \(body)") } - - throw CowtailAPIError.requestFailed(envelope.errorMessage ?? "Unknown Convex error") } - private func jsonRequest(url: URL, body: [String: Any]) async throws -> T { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await URLSession.shared.data(for: request) - try validate(response: response, data: data) - return try decode(T.self, from: data, context: "JSON response") + private func makeAlertItem(_ record: Components.Schemas.ConvexAlertRecord) -> AlertItem { + AlertItem( + id: record._id, + timestamp: Date(timeIntervalSince1970: record.timestamp / 1000), + alertName: record.alertname, + severity: AlertSeverity(rawValue: record.severity) ?? .unknown, + namespace: record.namespace, + node: record.node ?? "", + outcome: AlertOutcome(rawValue: record.outcome) ?? .unknown, + summary: record.summary, + rootCause: record.rootCause ?? "", + actionTaken: record.action ?? "", + status: AlertLifecycleStatus(rawValue: record.status) ?? .unknown, + resolvedAt: record.resolvedAt.map { Date(timeIntervalSince1970: $0 / 1000) }, + messaged: record.messaged ?? false + ) } - private func validate(response: URLResponse, data: Data) throws { - guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { - throw CowtailAPIError.requestFailed(parseErrorMessage(from: data)) - } + private func makeAlertFix(_ record: Components.Schemas.ConvexFixRecord) -> AlertFix { + AlertFix( + id: record._id, + description: record.description ?? "", + rootCause: record.rootCause ?? "", + scope: FixScope(rawValue: record.scope ?? "") ?? .unknown, + timestamp: Date(timeIntervalSince1970: record.timestamp / 1000) + ) } - private func parseErrorMessage(from data: Data) -> String { - if let envelope = try? decoder.decode(APIErrorEnvelope.self, from: data) { - return envelope.error - } - - return String(data: data, encoding: .utf8) ?? "Unknown error" + private func makeHealthSummary(_ payload: Components.Schemas.HealthResponse) -> HealthSummary { + HealthSummary( + nodes: payload.nodes.map { + HealthNode( + id: $0.name, + name: $0.name, + isReady: $0.status == .ready, + cpu: Int($0.cpu.rounded()), + memory: Int($0.memory.rounded()) + ) + }, + cephStatus: payload.cephStatus.rawValue, + cephMessage: payload.cephMessage, + storageTotal: payload.storageTotal, + storageUsed: payload.storageUsed, + storageUnit: payload.storageUnit + ) } - private func decode(_ type: T.Type, from data: Data, context: String) throws -> T { - do { - return try decoder.decode(T.self, from: data) - } catch let error as DecodingError { - throw CowtailAPIError.requestFailed("\(context) decode failed: \(describe(error, data: data))") - } catch { - throw error - } + private func milliseconds(_ date: Date) -> Double { + date.timeIntervalSince1970 * 1000 } - private func describe(_ error: DecodingError, data: Data) -> String { - let snippet = String(data: data.prefix(240), encoding: .utf8)? - .replacingOccurrences(of: "\n", with: " ") ?? "" - - switch error { - case .typeMismatch(let type, let context): - return "type mismatch for \(type) at \(codingPath(context.codingPath)): \(context.debugDescription). Payload: \(snippet)" - case .valueNotFound(let type, let context): - return "missing \(type) at \(codingPath(context.codingPath)): \(context.debugDescription). Payload: \(snippet)" - case .keyNotFound(let key, let context): - return "missing key '\(key.stringValue)' at \(codingPath(context.codingPath)): \(context.debugDescription). Payload: \(snippet)" - case .dataCorrupted(let context): - return "data corrupted at \(codingPath(context.codingPath)): \(context.debugDescription). Payload: \(snippet)" - @unknown default: - return "unknown decoding error. Payload: \(snippet)" - } + private func logUndocumentedResponse( + operation: String, + statusCode: Int, + payload: UndocumentedPayload + ) async { + let body = await bodyString(from: payload.body) + logger.error( + "\(operation, privacy: .public) undocumented response status=\(statusCode, privacy: .public) headers=\(String(describing: payload.headerFields), privacy: .public) body=\(body, privacy: .public)" + ) } - private func codingPath(_ codingPath: [CodingKey]) -> String { - if codingPath.isEmpty { - return "" + private func bodyString(from body: HTTPBody?) async -> String { + guard let body else { + return "" } - return codingPath.map(\.stringValue).joined(separator: ".") + do { + let bytes = try await Array(collecting: body, upTo: 32_768) + return String(decoding: bytes, as: UTF8.self) + } catch { + return "" + } } } @@ -191,164 +312,5 @@ enum CowtailAPIError: LocalizedError { } } -private struct ConvexEnvelope: Decodable { - let status: String - let value: T? - let errorMessage: String? -} - -private struct AlertDTO: Decodable { - let id: String - let timestamp: Double - let alertname: String - let severity: AlertSeverity - let namespace: String - let node: String? - let outcome: AlertOutcome - let summary: String - let rootCause: String? - let action: String? - let status: AlertLifecycleStatus - let resolvedAt: Double? - let messaged: Bool - - enum CodingKeys: String, CodingKey { - case id = "_id" - case timestamp - case alertname - case severity - case namespace - case node - case outcome - case summary - case rootCause - case action - case status - case resolvedAt - case messaged - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - timestamp = try container.decode(Double.self, forKey: .timestamp) - alertname = try container.decode(String.self, forKey: .alertname) - severity = try container.decodeIfPresent(AlertSeverity.self, forKey: .severity) ?? .unknown - namespace = try container.decodeIfPresent(String.self, forKey: .namespace) ?? "" - node = try container.decodeIfPresent(String.self, forKey: .node) - outcome = try container.decodeIfPresent(AlertOutcome.self, forKey: .outcome) ?? .unknown - summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" - rootCause = try container.decodeIfPresent(String.self, forKey: .rootCause) - action = try container.decodeIfPresent(String.self, forKey: .action) - status = try container.decodeIfPresent(AlertLifecycleStatus.self, forKey: .status) ?? .unknown - resolvedAt = try container.decodeIfPresent(Double.self, forKey: .resolvedAt) - messaged = try container.decodeIfPresent(Bool.self, forKey: .messaged) ?? false - } - - var asAlertItem: AlertItem { - AlertItem( - id: id, - timestamp: Date(timeIntervalSince1970: timestamp / 1000), - alertName: alertname, - severity: severity, - namespace: namespace, - node: node ?? "", - outcome: outcome, - summary: summary, - rootCause: rootCause ?? "", - actionTaken: action ?? "", - status: status, - resolvedAt: resolvedAt.map { Date(timeIntervalSince1970: $0 / 1000) }, - messaged: messaged - ) - } -} - -private struct FixDTO: Decodable { - let id: String - let description: String - let rootCause: String - let scope: FixScope - let timestamp: Double - - enum CodingKeys: String, CodingKey { - case id = "_id" - case description - case rootCause - case scope - case timestamp - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" - rootCause = try container.decodeIfPresent(String.self, forKey: .rootCause) ?? "" - scope = try container.decodeIfPresent(FixScope.self, forKey: .scope) ?? .unknown - timestamp = try container.decode(Double.self, forKey: .timestamp) - } - - var asAlertFix: AlertFix { - AlertFix( - id: id, - description: description, - rootCause: rootCause, - scope: scope, - timestamp: Date(timeIntervalSince1970: timestamp / 1000) - ) - } -} - -private struct HealthSummaryDTO: Decodable { - let version: Int? - let nodes: [HealthNodeDTO] - let cephStatus: String - let cephMessage: String - let storageTotal: Double - let storageUsed: Double - let storageUnit: String - - var asHealthSummary: HealthSummary { - HealthSummary( - nodes: nodes.map(\.asHealthNode), - cephStatus: cephStatus, - cephMessage: cephMessage, - storageTotal: storageTotal, - storageUsed: storageUsed, - storageUnit: storageUnit - ) - } -} - -private struct HealthNodeDTO: Decodable { - let name: String - let status: String - let cpu: Int - let memory: Int - - var asHealthNode: HealthNode { - HealthNode( - id: name, - name: name, - isReady: status == "Ready", - cpu: cpu, - memory: memory - ) - } -} - -struct PushRegistrationResponse: Decodable { - let ok: Bool - let created: Bool - let id: String -} - -struct PushUnregistrationResponse: Decodable { - let ok: Bool - let updated: Bool -} - -private struct APIErrorEnvelope: Decodable { - let ok: Bool - let error: String -} +typealias PushRegistrationResponse = Components.Schemas.PushRegisterResponse +typealias PushUnregistrationResponse = Components.Schemas.PushUnregisterResponse diff --git a/ios/OpenAPITools/Package.swift b/ios/OpenAPITools/Package.swift new file mode 100644 index 0000000..e50fecc --- /dev/null +++ b/ios/OpenAPITools/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "CowtailOpenAPITools", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.9.0"), + ], + targets: [ + .target( + name: "CowtailGeneratedAPI", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + ] + ), + ] +) diff --git a/ios/OpenAPITools/Sources/CowtailGeneratedAPI/CowtailGeneratedAPI.swift b/ios/OpenAPITools/Sources/CowtailGeneratedAPI/CowtailGeneratedAPI.swift new file mode 100644 index 0000000..af9b9b1 --- /dev/null +++ b/ios/OpenAPITools/Sources/CowtailGeneratedAPI/CowtailGeneratedAPI.swift @@ -0,0 +1,3 @@ +import OpenAPIRuntime + +enum CowtailGeneratedAPIPlaceholder {} diff --git a/ios/OpenAPITools/Sources/CowtailGeneratedAPI/openapi-generator-config.yaml b/ios/OpenAPITools/Sources/CowtailGeneratedAPI/openapi-generator-config.yaml new file mode 100644 index 0000000..ee9d836 --- /dev/null +++ b/ios/OpenAPITools/Sources/CowtailGeneratedAPI/openapi-generator-config.yaml @@ -0,0 +1,5 @@ +generate: + - types + - client +accessModifier: internal +namingStrategy: idiomatic diff --git a/ios/README.md b/ios/README.md index 8e8d02e..059cf3b 100644 --- a/ios/README.md +++ b/ios/README.md @@ -10,6 +10,7 @@ Current scope: - Apple sign-in session handling - push registration and notification-routing logic - universal links for alert URLs +- build-time generated OpenAPI client/types from the shared `protocol/` package - planning docs for future native parity work ## Local config @@ -29,6 +30,9 @@ cd ios Then open `ios/Cowtail.xcodeproj` in Xcode. +The project generation step also refreshes `OpenAPITools/Sources/CowtailGeneratedAPI/openapi.json` from the shared protocol package. +When building in Xcode, a pre-build script refreshes the OpenAPI spec from the shared protocol package and then regenerates Swift client/types into `OpenAPITools/Sources/CowtailGeneratedAPI/GeneratedSources` automatically. + See: - `docs/apple-setup.md` - `docs/architecture.md` diff --git a/ios/generate.sh b/ios/generate.sh index 9044320..4d9395e 100755 --- a/ios/generate.sh +++ b/ios/generate.sh @@ -33,4 +33,8 @@ do fi done +cd "${SCRIPT_DIR}/.." +bun run codegen:ios +cd "${SCRIPT_DIR}" + exec xcodegen generate diff --git a/ios/project.yml b/ios/project.yml index 420fc29..119dc0f 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -17,14 +17,61 @@ settings: configs: Debug: debug Release: release +packages: + SwiftOpenAPIRuntime: + url: https://github.com/apple/swift-openapi-runtime + from: 1.9.0 + SwiftOpenAPIURLSession: + url: https://github.com/apple/swift-openapi-urlsession + from: 1.1.0 targets: Cowtail: type: application platform: iOS deploymentTarget: "26.0" + info: + path: CowtailApp/Resources/Info.plist + properties: + CFBundleDisplayName: Cowtail + CFBundleShortVersionString: 0.1.0 + CFBundleVersion: "1" + CowtailPublicSiteURL: $(COWTAIL_PUBLIC_SITE_URL) + CowtailConvexQueryURL: $(COWTAIL_CONVEX_QUERY_URL) + CowtailHealthSummaryURL: $(COWTAIL_HEALTH_SUMMARY_URL) + CowtailAlertWriteURL: $(COWTAIL_ALERT_WRITE_URL) + CowtailPushRegistrationURL: $(COWTAIL_PUSH_REGISTRATION_URL) + CowtailPushUnregistrationURL: $(COWTAIL_PUSH_UNREGISTRATION_URL) + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UILaunchScreen: {} sources: - path: CowtailApp/Sources - path: CowtailApp/Resources + - path: OpenAPITools/Sources/CowtailGeneratedAPI/GeneratedSources + group: Generated/OpenAPI + optional: true + dependencies: + - package: SwiftOpenAPIRuntime + product: OpenAPIRuntime + - package: SwiftOpenAPIURLSession + product: OpenAPIURLSession + preBuildScripts: + - script: | + #!/usr/bin/env sh + set -eu + + REPO_ROOT="$(CDPATH= cd -- "${SRCROOT}/.." && pwd)" + + cd "${REPO_ROOT}" + bun run ./protocol/scripts/generate-ios-openapi.ts + xcrun --sdk macosx swift package \ + --package-path "${REPO_ROOT}/ios/OpenAPITools" \ + --allow-writing-to-package-directory \ + generate-code-from-openapi \ + --target CowtailGeneratedAPI + name: Sync OpenAPI + basedOnDependencyAnalysis: false + showEnvVars: false settings: base: PRODUCT_NAME: Cowtail @@ -32,24 +79,13 @@ targets: SWIFT_VERSION: 6.0 IPHONEOS_DEPLOYMENT_TARGET: 26.0 TARGETED_DEVICE_FAMILY: 1 - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_CFBundleDisplayName: Cowtail - INFOPLIST_KEY_CFBundleShortVersionString: 0.1.0 - INFOPLIST_KEY_CFBundleVersion: 1 - INFOPLIST_KEY_CowtailPublicSiteURL: $(COWTAIL_PUBLIC_SITE_URL) - INFOPLIST_KEY_CowtailConvexQueryURL: $(COWTAIL_CONVEX_QUERY_URL) - INFOPLIST_KEY_CowtailHealthSummaryURL: $(COWTAIL_HEALTH_SUMMARY_URL) - INFOPLIST_KEY_CowtailAlertWriteURL: $(COWTAIL_ALERT_WRITE_URL) - INFOPLIST_KEY_CowtailPushRegistrationURL: $(COWTAIL_PUSH_REGISTRATION_URL) - INFOPLIST_KEY_CowtailPushUnregistrationURL: $(COWTAIL_PUSH_UNREGISTRATION_URL) - INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor CODE_SIGN_STYLE: Automatic CURRENT_PROJECT_VERSION: 1 MARKETING_VERSION: 0.1.0 DEVELOPMENT_ASSET_PATHS: '"CowtailApp/Preview Content"' + ENABLE_USER_SCRIPT_SANDBOXING: NO configs: Debug: COWTAIL_ASSOCIATED_DOMAIN: ${COWTAIL_ASSOCIATED_DOMAIN_DEBUG} diff --git a/ios/scripts/sync-openapi.sh b/ios/scripts/sync-openapi.sh new file mode 100755 index 0000000..832e386 --- /dev/null +++ b/ios/scripts/sync-openapi.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT="$(CDPATH= cd -- "${SRCROOT:-$(dirname "$0")/..}" && pwd)" + +cd "${REPO_ROOT}" +bun run ./protocol/scripts/generate-ios-openapi.ts +xcrun --sdk macosx swift package \ + --package-path "${REPO_ROOT}/ios/OpenAPITools" \ + --allow-writing-to-package-directory \ + generate-code-from-openapi \ + --target CowtailGeneratedAPI \ diff --git a/package.json b/package.json index b5dd754..36d1fd3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ ], "scripts": { "build:cli": "cd cli && bun run build", + "codegen:ios": "cd protocol && bun run codegen:ios", "dev:web": "cd web && bun run dev", "check:cli": "cd cli && bun run check", "build:web": "cd web && bun run build", diff --git a/protocol/package.json b/protocol/package.json index 60c6b86..9354e73 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -14,12 +14,14 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit", + "codegen:ios": "bun run ./scripts/generate-ios-openapi.ts" }, "dependencies": { - "zod": "^3.25.76" + "zod": "^4.1.12" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "^8.1.0", "typescript": "^5.9.3" } } diff --git a/protocol/scripts/generate-ios-openapi.ts b/protocol/scripts/generate-ios-openapi.ts new file mode 100644 index 0000000..32dee45 --- /dev/null +++ b/protocol/scripts/generate-ios-openapi.ts @@ -0,0 +1,226 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +import { + healthResponseSchema, + pushRegisterRequestSchema, + pushRegisterResponseSchema, + pushUnregisterRequestSchema, + pushUnregisterResponseSchema, +} from "../src/index.js"; + +const registry = new OpenAPIRegistry(); + +const nonEmptyStringSchema = z.string().trim().min(1); +const timestampSchema = z.number().finite().nonnegative(); +const convexStatusSchema = z.enum(["success", "error"]); +const convexFormatSchema = z.literal("convex_encoded_json"); + +const convexAlertRecordSchema = z + .object({ + _id: nonEmptyStringSchema, + timestamp: timestampSchema, + alertname: nonEmptyStringSchema, + severity: z.string(), + namespace: z.string(), + node: z.string().optional(), + status: z.string(), + outcome: z.string(), + summary: z.string(), + action: z.string().optional(), + rootCause: z.string().optional(), + resolvedAt: timestampSchema.optional(), + messaged: z.boolean().optional(), + }) + .meta({ id: "ConvexAlertRecord" }); + +const convexFixRecordSchema = z + .object({ + _id: nonEmptyStringSchema, + description: z.string().optional(), + rootCause: z.string().optional(), + scope: z.string().optional(), + timestamp: timestampSchema, + }) + .meta({ id: "ConvexFixRecord" }); + +const fetchAlertsRequestSchema = z + .object({ + path: z.literal("alerts:getByTimeRange"), + args: z.object({ + from: timestampSchema, + to: timestampSchema, + }), + format: convexFormatSchema, + }) + .meta({ id: "ConvexFetchAlertsRequest" }); + +const fetchAlertRequestSchema = z + .object({ + path: z.literal("alerts:getById"), + args: z.object({ + id: nonEmptyStringSchema, + }), + format: convexFormatSchema, + }) + .meta({ id: "ConvexFetchAlertRequest" }); + +const fetchFixesRequestSchema = z + .object({ + path: z.literal("fixes:getByAlertIds"), + args: z.object({ + alertIds: z.array(nonEmptyStringSchema).min(1), + }), + format: convexFormatSchema, + }) + .meta({ id: "ConvexFetchFixesRequest" }); + +const convexQueryRequestSchema = z + .union([fetchAlertsRequestSchema, fetchAlertRequestSchema, fetchFixesRequestSchema]) + .meta({ id: "ConvexQueryRequest" }); + +const fetchAlertsResponseSchema = z + .object({ + status: convexStatusSchema, + value: z.array(convexAlertRecordSchema).optional(), + errorMessage: z.string().optional(), + }) + .meta({ id: "ConvexFetchAlertsResponse" }); + +const fetchAlertResponseSchema = z + .object({ + status: convexStatusSchema, + value: z.union([convexAlertRecordSchema, z.null()]).optional(), + errorMessage: z.string().optional(), + }) + .meta({ id: "ConvexFetchAlertResponse" }); + +const fetchFixesResponseSchema = z + .object({ + status: convexStatusSchema, + value: z.array(convexFixRecordSchema).optional(), + errorMessage: z.string().optional(), + }) + .meta({ id: "ConvexFetchFixesResponse" }); + +const convexQueryResponseSchema = z + .union([fetchAlertsResponseSchema, fetchAlertResponseSchema, fetchFixesResponseSchema]) + .meta({ id: "ConvexQueryResponse" }); + +const HealthResponse = healthResponseSchema.meta({ id: "HealthResponse" }); +const PushRegisterRequest = pushRegisterRequestSchema.meta({ id: "PushRegisterRequest" }); +const PushRegisterResponse = pushRegisterResponseSchema.meta({ id: "PushRegisterResponse" }); +const PushUnregisterRequest = pushUnregisterRequestSchema.meta({ id: "PushUnregisterRequest" }); +const PushUnregisterResponse = pushUnregisterResponseSchema.meta({ id: "PushUnregisterResponse" }); + +function jsonContent(schema: z.ZodTypeAny) { + return { + "application/json": { + schema, + }, + }; +} + +function jsonResponse(description: string, schema: z.ZodTypeAny) { + return { + description, + content: jsonContent(schema), + }; +} + +registry.registerPath({ + method: "post", + path: "/query", + operationId: "query", + tags: ["convexQuery"], + summary: "Perform a typed Convex query request", + request: { + body: { + required: true, + content: jsonContent(convexQueryRequestSchema), + }, + }, + responses: { + 200: jsonResponse("Convex query response.", convexQueryResponseSchema), + }, +}); + +registry.registerPath({ + method: "get", + path: "/health", + operationId: "fetchHealthSummary", + tags: ["health"], + summary: "Fetch the cluster health payload", + responses: { + 200: jsonResponse("The cluster health payload.", HealthResponse), + }, +}); + +registry.registerPath({ + method: "post", + path: "/push/register", + operationId: "registerPushDevice", + tags: ["push"], + summary: "Register or refresh an iOS push device token", + request: { + body: { + required: true, + content: jsonContent(PushRegisterRequest), + }, + }, + responses: { + 200: jsonResponse("The device registration result.", PushRegisterResponse), + }, +}); + +registry.registerPath({ + method: "post", + path: "/push/unregister", + operationId: "unregisterPushDevice", + tags: ["push"], + summary: "Disable an existing push device token", + request: { + body: { + required: true, + content: jsonContent(PushUnregisterRequest), + }, + }, + responses: { + 200: jsonResponse("The device unregistration result.", PushUnregisterResponse), + }, +}); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "..", ".."); +const outputPath = resolve( + repoRoot, + "ios", + "OpenAPITools", + "Sources", + "CowtailGeneratedAPI", + "openapi.json", +); + +const generator = new OpenApiGeneratorV3(registry.definitions); +const document = generator.generateDocument({ + openapi: "3.0.3", + info: { + title: "Cowtail iOS API", + version: "1.0.0", + description: "Generated from the working Cowtail iOS transport contract.", + }, + servers: [ + { + url: "/api", + description: "Generic API base shared by the generated clients.", + }, + ], +}); + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); +console.log(`Wrote ${outputPath}`); diff --git a/protocol/src/push.ts b/protocol/src/push.ts index 6c83c8f..149c66d 100644 --- a/protocol/src/push.ts +++ b/protocol/src/push.ts @@ -2,6 +2,29 @@ import { z } from "zod"; import { jsonObjectSchema, nonEmptyStringSchema } from "./shared.js"; +export const pushRegisterRequestSchema = z.object({ + identityToken: nonEmptyStringSchema, + deviceToken: nonEmptyStringSchema, + platform: nonEmptyStringSchema.optional(), + environment: nonEmptyStringSchema.optional(), + deviceName: nonEmptyStringSchema.optional(), +}); + +export const pushRegisterResponseSchema = z.object({ + ok: z.literal(true), + created: z.boolean(), + id: nonEmptyStringSchema, +}); + +export const pushUnregisterRequestSchema = z.object({ + deviceToken: nonEmptyStringSchema, +}); + +export const pushUnregisterResponseSchema = z.object({ + ok: z.literal(true), + updated: z.boolean(), +}); + export const pushSendRequestSchema = z.object({ userId: nonEmptyStringSchema, title: nonEmptyStringSchema, @@ -23,6 +46,10 @@ export const pushResultSchema = z.object({ failed: z.number().int().nonnegative(), results: z.array(jsonObjectSchema), }); +export type PushRegisterRequest = z.infer; +export type PushRegisterResponse = z.infer; +export type PushUnregisterRequest = z.infer; +export type PushUnregisterResponse = z.infer; export type PushSendRequest = z.infer; export type PushTestRequest = z.infer; export type PushResult = z.infer; diff --git a/protocol/src/shared.ts b/protocol/src/shared.ts index 30a2b79..81883f8 100644 --- a/protocol/src/shared.ts +++ b/protocol/src/shared.ts @@ -6,4 +6,4 @@ export const fixScopes = ["reactive", "weekly", "monthly"] as const; export const nonEmptyStringSchema = z.string().trim().min(1); export const timestampSchema = z.number().int().nonnegative(); -export const jsonObjectSchema = z.record(z.unknown()); +export const jsonObjectSchema = z.record(z.string(), z.unknown()); diff --git a/web/convex/http.ts b/web/convex/http.ts index 2499d25..18d2e94 100644 --- a/web/convex/http.ts +++ b/web/convex/http.ts @@ -14,9 +14,13 @@ import { fixCreateRequestSchema, healthResponseSchema, okResponseSchema, + pushRegisterRequestSchema, + pushRegisterResponseSchema, pushResultSchema, pushSendRequestSchema, pushTestRequestSchema, + pushUnregisterRequestSchema, + pushUnregisterResponseSchema, userDevicesResponseSchema, usersListResponseSchema, } from "@maudecode/cowtail-protocol"; @@ -570,12 +574,17 @@ app.post("/api/push/register", async (c) => { return jsonError("Invalid JSON body"); } - const identityToken = nonEmptyString(body.identityToken); + const parsed = pushRegisterRequestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(formatIssues(parsed.error.issues), 400); + } + + const identityToken = nonEmptyString(parsed.data.identityToken); if (!identityToken) { return jsonError("identityToken and deviceToken are required"); } - const deviceToken = nonEmptyString(body.deviceToken); + const deviceToken = nonEmptyString(parsed.data.deviceToken); if (!deviceToken) { return jsonError("identityToken and deviceToken are required"); } @@ -597,15 +606,15 @@ app.post("/api/push/register", async (c) => { const result = await ctx.runMutation(api.push.upsertDeviceRegistration, { userId, deviceToken: deviceToken.trim(), - platform: nonEmptyString(body.platform) ?? "ios", + platform: nonEmptyString(parsed.data.platform) ?? "ios", environment: - nonEmptyString(body.environment) ?? (process.env.APNS_ENV?.trim() || "development"), + nonEmptyString(parsed.data.environment) ?? (process.env.APNS_ENV?.trim() || "development"), enabled: true, - deviceName: nonEmptyString(body.deviceName), + deviceName: nonEmptyString(parsed.data.deviceName), lastSeenAt: Date.now(), }); - return c.json({ ok: true, ...result }); + return c.json(pushRegisterResponseSchema.parse({ ok: true, ...result })); }); // POST /api/push/unregister — disable a device token without deleting history @@ -615,17 +624,17 @@ app.post("/api/push/unregister", async (c) => { return jsonError("Invalid JSON body"); } - const deviceToken = nonEmptyString(body.deviceToken); - if (!deviceToken) { - return jsonError("deviceToken is required"); + const parsed = pushUnregisterRequestSchema.safeParse(body); + if (!parsed.success) { + return jsonError(formatIssues(parsed.error.issues), 400); } const ctx = c.env; const result = await ctx.runMutation(api.push.disableDeviceRegistrationByToken, { - deviceToken: deviceToken.trim(), + deviceToken: parsed.data.deviceToken.trim(), }); - return c.json(result); + return c.json(pushUnregisterResponseSchema.parse(result)); }); async function sendPushToUser(