1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import {
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.
17export const APNS_JWT_REUSE_SECONDS = 45 * 60;
18
19export class ApnsProviderTokens extends Context.Service<
20 ApnsProviderTokens,
21 {
22 readonly getJwt: (input: ApnsJwtSigningInput) => Effect.Effect<string, ApnsJwtError>;
23 }
24>()("t3code-relay/agentActivity/ApnsProviderTokens") {}
25
26interface 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.
35const isolateTokenCache = new Map<string, CachedProviderToken>();
36
37export 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.
44export function quantizedApnsJwtIssuedAt(nowUnixSeconds: number): number {
45 return Math.floor(nowUnixSeconds / APNS_JWT_REUSE_SECONDS) * APNS_JWT_REUSE_SECONDS;
46}
47
48export 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
63export const layer = Layer.succeed(ApnsProviderTokens, make());