| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | |
| 5 | import { |
| 6 | apnsProviderTokenCacheKey, |
| 7 | makeApnsJwt, |
| 8 | type ApnsJwtError, |
| 9 | type ApnsJwtSigningInput, |
| 10 | } from "./apnsJwt.ts"; |
| 11 | |
| 12 | // APNs requires REUSING the provider token: refreshing it more than roughly |
| 13 | // once per 20 minutes returns 429 TooManyProviderTokenUpdates and drops the |
| 14 | // push (observed live: bursty Live Activity updates got 429'd, leaving stale |
| 15 | // lock-screen state). Reuse each signed JWT for most of its 60-minute |
| 16 | // validity. |
| 17 | export const APNS_JWT_REUSE_SECONDS = 45 * 60; |
| 18 | |
| 19 | export class ApnsProviderTokens extends Context.Service< |
| 20 | ApnsProviderTokens, |
| 21 | { |
| 22 | readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect<string, ApnsJwtError>; |
| 23 | } |
| 24 | >()("t3code-relay/agentActivity/ApnsProviderTokens") {} |
| 25 | |
| 26 | interface CachedProviderToken { |
| 27 | readonly jwt: string; |
| 28 | readonly issuedAtUnixSeconds: number; |
| 29 | } |
| 30 | |
| 31 | // Signing is deterministic (RFC 6979) and iat is quantized below, so every |
| 32 | // isolate independently derives the byte-identical token for a window; this |
| 33 | // map only avoids re-signing on every push. No shared storage is needed, and |
| 34 | // no provider token is ever written anywhere. |
| 35 | const isolateTokenCache = new Map<string, CachedProviderToken>(); |
| 36 | |
| 37 | export function __resetApnsProviderTokenCacheForTest(): void { |
| 38 | isolateTokenCache.clear(); |
| 39 | } |
| 40 | |
| 41 | // Quantize iat to the reuse window so all isolates agree on it. The token's |
| 42 | // age stays under APNs' 60-minute limit, and the whole fleet rolls to the |
| 43 | // next token at the same instant — one provider-token update per window. |
| 44 | export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number { |
| 45 | return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS; |
| 46 | } |
| 47 | |
| 48 | export const make = () => |
| 49 | ApnsProviderTokens.of({ |
| 50 | getJwt: Effect.fnUntraced(function* (input) { |
| 51 | const issuedAtUnixSeconds = quantizedApnsJwtIssuedAt(input.issuedAtUnixSeconds); |
| 52 | const cacheKey = apnsProviderTokenCacheKey(input); |
| 53 | const cached = isolateTokenCache.get(cacheKey); |
| 54 | if (cached && cached.issuedAtUnixSeconds === issuedAtUnixSeconds) { |
| 55 | return cached.jwt; |
| 56 | } |
| 57 | const jwt = yield* makeApnsJwt({ ...input, issuedAtUnixSeconds }); |
| 58 | isolateTokenCache.set(cacheKey, { jwt, issuedAtUnixSeconds }); |
| 59 | return jwt; |
| 60 | }), |
| 61 | }); |
| 62 | |
| 63 | export const layer = Layer.succeed(ApnsProviderTokens, make()); |