T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected) T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT (node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode, single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22 Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
100 lines
4.1 KiB
Swift
100 lines
4.1 KiB
Swift
import Foundation
|
||
import WireProtocol
|
||
|
||
/// APNs device-token registration (T-iOS-38 · frozen wire shape, orchestrator
|
||
/// ruling — mirrors the `POST /push/subscribe` conventions, src/server.ts:461-480):
|
||
///
|
||
/// - `POST /push/apns-token` body `{"token":"<64-160 hex chars, lowercase>"}`
|
||
/// → **204** (idempotent upsert)
|
||
/// - `DELETE /push/apns-token` body 同形 → **204** (idempotent; unknown token
|
||
/// still 204)
|
||
/// - Both are **G endpoints**: `Origin` guard (`requireAllowedOrigin`) → 403.
|
||
/// - Server limits: 400 invalid token/shape · **429 rate limit 5/min/IP** ·
|
||
/// `express.json` body limit **8 KB** (SUBSCRIBE_RATE_MAX, src/server.ts:73).
|
||
///
|
||
/// The hex rule is enforced CLIENT-SIDE too (validate at the boundary, plan §4):
|
||
/// uppercase input is lowercase-normalized, anything not 64–160 hex chars is
|
||
/// rejected with `.invalidApnsToken` BEFORE any network I/O.
|
||
enum ApnsTokenRule {
|
||
/// APNs device tokens are ≥ 32 bytes (64 hex chars) today; the frozen
|
||
/// shape allows up to 160 hex chars of forward headroom.
|
||
static let minHexLength = 64
|
||
static let maxHexLength = 160
|
||
/// ASCII-only hex alphabet — deliberately NOT `Character.isHexDigit`
|
||
/// (which also accepts fullwidth digit forms the server regex rejects).
|
||
private static let hexDigits = Set("0123456789abcdef")
|
||
|
||
/// Lowercase-normalize and validate; nil = not a valid wire token.
|
||
static func normalize(_ raw: String) -> String? {
|
||
let lowered = raw.lowercased()
|
||
guard (minHexLength...maxHexLength).contains(lowered.count),
|
||
lowered.allSatisfy({ hexDigits.contains($0) })
|
||
else { return nil }
|
||
return lowered
|
||
}
|
||
}
|
||
|
||
extension Endpoints {
|
||
static let apnsTokenPath = "/push/apns-token"
|
||
|
||
/// `POST /push/apns-token` — G. `normalized` MUST come from
|
||
/// `ApnsTokenRule.normalize` (single validation point).
|
||
static func registerApnsToken(normalized: String) throws -> APIRoute {
|
||
APIRoute(
|
||
method: .post, path: apnsTokenPath, originPolicy: .guarded,
|
||
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
|
||
)
|
||
}
|
||
|
||
/// `DELETE /push/apns-token` — G, idempotent (unknown token still 204).
|
||
static func unregisterApnsToken(normalized: String) throws -> APIRoute {
|
||
APIRoute(
|
||
method: .delete, path: apnsTokenPath, originPolicy: .guarded,
|
||
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
|
||
)
|
||
}
|
||
|
||
private struct ApnsTokenBody: Encodable {
|
||
let token: String
|
||
}
|
||
}
|
||
|
||
extension APIClient {
|
||
/// Register this device's APNs token (idempotent upsert → 204).
|
||
/// G endpoint — Origin byte-equal to `endpoint.originHeader`.
|
||
/// Server limits (documented, enforced server-side): body ≤ 8 KB,
|
||
/// ≤ 5 requests/min/IP → `.rateLimited`. Invalid hex is rejected
|
||
/// client-side (`.invalidApnsToken`) before any network I/O.
|
||
public func registerApnsToken(_ token: String) async throws {
|
||
try await sendApnsToken(token, build: Endpoints.registerApnsToken(normalized:))
|
||
}
|
||
|
||
/// Unregister a previously registered token (idempotent → 204 even for an
|
||
/// unknown token). Same G guard and limits as `registerApnsToken`.
|
||
public func unregisterApnsToken(_ token: String) async throws {
|
||
try await sendApnsToken(token, build: Endpoints.unregisterApnsToken(normalized:))
|
||
}
|
||
|
||
private func sendApnsToken(
|
||
_ token: String,
|
||
build: (String) throws -> APIRoute
|
||
) async throws {
|
||
guard let normalized = ApnsTokenRule.normalize(token) else {
|
||
throw APIClientError.invalidApnsToken
|
||
}
|
||
let (_, response) = try await perform(try build(normalized))
|
||
switch response.statusCode {
|
||
case HTTPStatus.noContent:
|
||
return
|
||
case HTTPStatus.badRequest:
|
||
throw APIClientError.invalidApnsToken
|
||
case HTTPStatus.forbidden:
|
||
throw APIClientError.forbidden
|
||
case HTTPStatus.tooManyRequests:
|
||
throw APIClientError.rateLimited
|
||
default:
|
||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||
}
|
||
}
|
||
}
|