- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
121 lines
5.6 KiB
Swift
121 lines
5.6 KiB
Swift
#!/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)
|
|
}
|