Files
web-terminal/ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift
Yaojia Wang 4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
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
2026-07-05 13:34:01 +02:00

100 lines
4.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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