#!/usr/bin/env swift // // Regenerates contracts/app-routes.json in the simadmin-relay repository from UpstreamRoute. // // This file exists because the vendored route list and the app's own enum are two copies of one // fact, and the relay repository's policy test asserts the agent's allowlist equals the vendored // copy. Without a generator the chain has a hand-edited link in the middle: the policy test would // pass against a stale file while the app had already grown a route, and the failure would surface // at runtime as "policy_denied" for an operation the UI offers. // // The Swift test `AppRoutesContractTests` checks the reverse direction — that the vendored file // matches what the app can construct — so the two together make drift a build failure rather than a // field report. // // Usage: // swift scripts/export-app-routes.swift [output-path] // // The default output path assumes the two repositories sit side by side, which is how they are // checked out here: /Users/chick/.local/opt/multi-simadmin and /Users/chick/.local/opt/simadmin-relay. import Foundation // MARK: - The route surface, mirrored from UpstreamRoute /// One route: the method and the path, with the query shape the app actually sends. /// /// This is deliberately a separate declaration rather than a parse of `UpstreamRoute.swift`. A /// parser would have to evaluate Swift to resolve the `path` computed property, and a generator that /// half-understands the enum is worse than one that states the surface and is checked against it. struct Route { let method: String let path: String /// True when the route carries a query string the app varies at runtime. The vendored file /// records the path without the query, because the agent matches on the path prefix and /// forwards whatever query arrived. let dynamicQuery: Bool } let routes: [Route] = [ Route(method: "GET", path: "/api/health", dynamicQuery: false), Route(method: "GET", path: "/api/device", dynamicQuery: false), Route(method: "GET", path: "/api/sim", dynamicQuery: false), Route(method: "GET", path: "/api/network", dynamicQuery: false), Route(method: "GET", path: "/api/network/signal-strength", dynamicQuery: false), Route(method: "GET", path: "/api/stats", dynamicQuery: false), Route(method: "GET", path: "/api/stats/cpu", dynamicQuery: false), Route(method: "GET", path: "/api/connectivity", dynamicQuery: false), Route(method: "GET", path: "/api/sms/list", dynamicQuery: true), Route(method: "POST", path: "/api/sms/send", dynamicQuery: false), Route(method: "POST", path: "/api/sms/batch-delete", dynamicQuery: false), ] /// Login and logout are listed separately and never proxied: the agent logs into its local /// SimAdmin itself, which is what keeps a node password from ever traversing the relay. let authRoutes = ["/api/auth/login", "/api/auth/logout"] // MARK: - Generation let sourceFile = "apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift" let defaultOutput = FileManager.default.currentDirectoryPath .replacingOccurrences(of: "/apps/ios", with: "") .appending("/../simadmin-relay/contracts/app-routes.json") let outputPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : URL(fileURLWithPath: defaultOutput).standardizedFileURL.path /// Serialised by hand rather than with `JSONSerialization`, so the file keeps the key order and the /// two-space indentation a reviewer expects to see in a diff. A generator whose output churns on /// every run is a generator people stop reading. var lines: [String] = [] lines.append("{") lines.append(" \"schemaVersion\": 1,") lines.append(" \"name\": \"msa-app-routes\",") lines.append(" \"comment\": \"Vendored copy of every upstream path the iPhone app can construct, derived from UpstreamRoute in multi-simadmin. This file exists so the agent policy can be pinned to what the app actually asks for. Regenerate it, never hand-edit it.\",") lines.append(" \"source\": {") lines.append(" \"repository\": \"Hermes/multi-simadmin\",") lines.append(" \"branch\": \"ios-native-ipa\",") lines.append(" \"file\": \"\(sourceFile)\",") lines.append(" \"generator\": \"scripts/export-app-routes.swift\"") lines.append(" },") lines.append(" \"auth\": [\(authRoutes.map { "\"\($0)\"" }.joined(separator: ", "))],") lines.append(" \"routes\": [") for (index, route) in routes.enumerated() { let suffix = index == routes.count - 1 ? "" : "," var entry = " { \"method\": \"\(route.method)\", \"path\": \"\(route.path)\"" if route.dynamicQuery { entry += ", \"query\": \"dynamic\"" } entry += " }\(suffix)" lines.append(entry) } lines.append(" ]") lines.append("}") let document = lines.joined(separator: "\n") + "\n" // Validate before writing: a generated file that the policy test would reject is worse than no // regeneration at all, because it looks authoritative. guard Set(routes.map(\.path)).count == routes.count else { FileHandle.standardError.write(Data("export-app-routes: duplicate paths in the route list\n".utf8)) exit(1) } for route in routes { guard route.path.hasPrefix("/api/") else { FileHandle.standardError.write(Data("export-app-routes: \(route.path) is not under /api/\n".utf8)) exit(1) } guard !route.path.contains("?") else { FileHandle.standardError.write(Data("export-app-routes: \(route.path) carries a query string; the vendored path must not\n".utf8)) exit(1) } } do { try document.write(toFile: outputPath, atomically: true, encoding: .utf8) print("wrote \(outputPath)") } catch { FileHandle.standardError.write(Data("export-app-routes: \(error)\n".utf8)) exit(1) }