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(