Relay & Durable Objects

Per-User Strong Consistency: Subscription State in a Durable Object

TermOnMac manages subscription state — tier, expiry, Apple notification history — in a per-user Durable Object (SubscriptionDO). The design comment at the top of the file states the intent directly:

// relay_server/src/subscription-do.ts
// SubscriptionDO — per-user Durable Object that serializes all subscription writes.
// DO storage is the source of truth for tier and subscription state (strongly consistent).
// KV is written as a cache but not relied upon for decisions.

Cloudflare KV is eventually consistent. Using it as the authoritative source for subscription tier could allow stale reads — a user who just cancelled might briefly appear as Pro. Using DO storage avoids this: all reads and writes to a given DO instance are serialized.

Storage Schema

The DO stores three types of data:

tier          → UserTier ("free" | "pro" | "premium")
subscription  → JWSPayload (productId, expiresDate, transactionId, ...)
uuid:{UUID}   → timestamp (notification dedup, 7-day TTL)
lastSignedDate → number (for stale downgrade protection)

Lazy Expiry Check

Subscription expiry is checked lazily on read, not via a scheduled job:

private async handleGetState(request: Request): Promise<Response> {
  let tier = await this.state.storage.get<string>("tier") ?? "free";

  // Lazy expiry check — downgrade if subscription has expired
  const sub = await this.state.storage.get("subscription") as Record<string, unknown> | null;
  if (sub?.expires_date && (sub.expires_date as number) < Date.now() && tier !== "free") {
    tier = "free";
    await this.state.storage.put("tier", "free");
    await this.state.storage.delete("subscription");
  }
  // ...
}

When the iOS app checks subscription status (/state), the DO compares expires_date against Date.now(). If expired, it immediately downgrades to free. No background job or polling is needed.

Notification Dedup

Apple sends App Store Server Notifications for subscription lifecycle events (renewal, expiry, refund). Each notification has a unique notificationUUID. The DO uses this UUID to deduplicate notifications:

private async handleAppleNotification(request: Request): Promise<Response> {
  const { signedPayload, notificationUUID, signedDate, notificationType } = await request.json();

  // 1. Dedup: skip if this notificationUUID was already processed
  if (notificationUUID) {
    const existing = await this.state.storage.get(`uuid:${notificationUUID}`);
    if (existing) {
      return Response.json({ handled: true, detail: "duplicate" });
    }
  }
  // ...
  // Record UUID after processing
  if (notificationUUID) {
    await this.state.storage.put(`uuid:${notificationUUID}`, Date.now());
  }
}

Apple may deliver the same notification multiple times (retries on HTTP 5xx). Without dedup, a network hiccup could cause a user to be downgraded or upgraded multiple times.

Stale Downgrade Protection

Out-of-order notification delivery is a real concern: an EXPIRED notification from last week could arrive after a DID_RENEW notification from today. Without protection, the old EXPIRED would incorrectly downgrade a currently-active subscriber.

The DO tracks the signedDate of the last processed notification and rejects EXPIRED / GRACE_PERIOD_EXPIRED notifications that are older:

private static readonly STALE_PROTECTED: ReadonlySet<string> = new Set([
  "EXPIRED",
  "GRACE_PERIOD_EXPIRED",
  // REFUND and REVOKE are NOT included — Apple-initiated revocations must always be honored.
]);

if (notificationType && signedDate && SubscriptionDO.STALE_PROTECTED.has(notificationType)) {
  const lastSignedDate = await this.state.storage.get<number>("lastSignedDate") ?? 0;
  if (signedDate < lastSignedDate) {
    return Response.json({ handled: true, detail: "stale_downgrade_rejected" });
  }
}

REFUND and REVOKE are intentionally excluded from stale protection — Apple-initiated revocations must always be honored regardless of ordering.

24-Hour Cleanup Alarm

The UUID dedup keys have a 7-day TTL in DO storage (UUID_TTL_MS = 7 * 24 * 60 * 60 * 1000). A DO alarm runs every 24 hours to delete expired entries:

const UUID_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const ALARM_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours

async alarm(): Promise<void> {
  const cutoff = Date.now() - UUID_TTL_MS;
  const entries = await this.state.storage.list({ prefix: "uuid:" });
  const toDelete: string[] = [];
  for (const [key, ts] of entries) {
    if (typeof ts === "number" && ts < cutoff) {
      toDelete.push(key);
    }
  }
  if (toDelete.length > 0) {
    await this.state.storage.delete(toDelete);
  }
  this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
}

The alarm is registered in the constructor to ensure it’s always scheduled:

constructor(state: DurableObjectState, env: Env) {
  state.storage.getAlarm().then(alarm => {
    if (!alarm) state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
  });
}

Admin Override

The /admin-set-tier endpoint writes tier directly to DO storage, bypassing Apple verification:

// Write tier only to DO storage (not KV profile)
await this.state.storage.put("tier", tier);

When downgrading to free via admin, it also clears the subscription record and the Apple transaction reverse index from KV:

if (tier === "free") {
  await this.env.AUTH_KV.delete(`apple_txn:${sub.original_transaction_id}`);
  await this.env.AUTH_KV.delete(`subscription:${userId}`);
  await this.state.storage.delete("subscription");
}