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) } } }