From 5bc030125a374a9c51d12596745cd644267b2f7d Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 20 May 2026 15:54:36 +0800 Subject: [PATCH] feat(notifications): defer FCM registration until auth is ready Queue token registration when Bearer auth is unavailable at startup, with bounded exponential backoff retries. Flush the pending token when identity is set, the app resumes, or the native fetcher configures. Skip refresh API calls when auth is missing and log lifecycle events for registration wait and refresh skip. --- src/main.capacitor.ts | 2 + .../NativeNotificationService.ts | 4 +- .../notifications/firebaseMessagingClient.ts | 12 ++ src/services/notifications/index.ts | 5 + .../notifications/nativeFetcherConfig.ts | 2 + .../notifications/notificationApiAuth.ts | 8 ++ .../notificationAuthLifecycle.ts | 115 ++++++++++++++++++ src/services/platforms/BaseDatabaseService.ts | 6 + src/utils/PlatformServiceMixin.ts | 6 + 9 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/services/notifications/notificationAuthLifecycle.ts diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 73156a1f..8e46aee8 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -46,6 +46,7 @@ import "@timesafari/daily-notification-plugin"; import { configureNativeFetcherIfReady, initializeNativePushAndFirebaseMessaging, + onNotificationAuthMayBeReady, } from "@/services/notifications"; logger.log("[Capacitor] 🚀 Starting initialization"); @@ -465,6 +466,7 @@ if ( // Refresh JWT for background New Activity prefetch (WorkManager cannot run JS; // short-lived tokens would expire between configure and T−5 fetch without this). await configureNativeFetcherIfReady(); + onNotificationAuthMayBeReady(); } }); } diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index 1d066a89..82c3a9b6 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -30,8 +30,8 @@ import { import { getNotificationApiHeaders, httpAuthErrorMessage, - logNotificationAuthFailure, logNotificationRequestAuthenticated, + logSkippingRefreshDueToMissingAuth, } from "./notificationApiAuth"; import { logNotification } from "./NotificationDebugEvents"; @@ -593,7 +593,7 @@ export async function refreshNotificationsWithDiagnostics(options?: { try { const auth = await getNotificationApiHeaders(); if (!auth.ok) { - logNotificationAuthFailure("refresh", auth.message); + logSkippingRefreshDueToMissingAuth(); logRefreshFailure(startedAt, auth.message); return { ok: false, diff --git a/src/services/notifications/firebaseMessagingClient.ts b/src/services/notifications/firebaseMessagingClient.ts index 0dda6047..3475212d 100644 --- a/src/services/notifications/firebaseMessagingClient.ts +++ b/src/services/notifications/firebaseMessagingClient.ts @@ -22,6 +22,8 @@ import { } from "firebase/messaging"; import { logger } from "@/utils/logger"; import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService"; +import { getNotificationApiHeaders } from "./notificationApiAuth"; +import { deferFcmRegistration } from "./notificationAuthLifecycle"; import { registerToken } from "./NotificationService"; import { logPushNotificationActionPerformed, @@ -51,6 +53,16 @@ async function registerRetrievedToken( logTokenRegistrationSkippedDuplicate(trimmed); return; } + + const auth = await getNotificationApiHeaders(); + if (!auth.ok) { + if (options?.force) { + throw new Error(`FCM registration auth unavailable: ${auth.message}`); + } + deferFcmRegistration(trimmed); + return; + } + await registerToken(trimmed); lastRegisteredFcmToken = trimmed; } diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index 43006e0c..2d020cae 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -34,6 +34,11 @@ export { NativeNotificationService } from "./NativeNotificationService"; export { WebPushNotificationService } from "./WebPushNotificationService"; export { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; +export { + deferFcmRegistration, + flushDeferredFcmRegistration, + onNotificationAuthMayBeReady, +} from "./notificationAuthLifecycle"; export { ensureFirebaseApp, initializeNativePushAndFirebaseMessaging, diff --git a/src/services/notifications/nativeFetcherConfig.ts b/src/services/notifications/nativeFetcherConfig.ts index f747d767..719a249d 100644 --- a/src/services/notifications/nativeFetcherConfig.ts +++ b/src/services/notifications/nativeFetcherConfig.ts @@ -12,6 +12,7 @@ import { mintBackgroundJwtTokenPool } from "@/libs/crypto"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { logger } from "@/utils/logger"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; +import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle"; /** * Configure the native notification content fetcher with API credentials. @@ -90,6 +91,7 @@ export async function configureNativeFetcherIfReady( jwtTokens.length + ")", ); + onNotificationAuthMayBeReady(); return true; } catch (error) { logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error); diff --git a/src/services/notifications/notificationApiAuth.ts b/src/services/notifications/notificationApiAuth.ts index 0a061cce..51f172f9 100644 --- a/src/services/notifications/notificationApiAuth.ts +++ b/src/services/notifications/notificationApiAuth.ts @@ -93,6 +93,14 @@ export function logNotificationAuthFailure( logNotification(`${verb} auth unavailable: ${message}`); } +export function logWaitingForAuthBeforeRegistration(): void { + logNotification("Waiting for auth before registration"); +} + +export function logSkippingRefreshDueToMissingAuth(): void { + logNotification("Skipping refresh due to missing auth"); +} + export function httpAuthErrorMessage(status: number): string { if (status === 401) { return "unauthorized (expired or invalid auth)"; diff --git a/src/services/notifications/notificationAuthLifecycle.ts b/src/services/notifications/notificationAuthLifecycle.ts new file mode 100644 index 00000000..a60dc297 --- /dev/null +++ b/src/services/notifications/notificationAuthLifecycle.ts @@ -0,0 +1,115 @@ +/** + * Defers notification register/refresh until app auth (active DID + Bearer) is available. + * Bounded retries avoid racing startup and prevent infinite loops. + */ + +import { logger } from "@/utils/logger"; +import { + getNotificationApiHeaders, + logWaitingForAuthBeforeRegistration, +} from "./notificationApiAuth"; +import { registerToken } from "./NotificationService"; + +const MAX_REGISTER_RETRY_ATTEMPTS = 6; +const REGISTER_RETRY_BASE_MS = 2_000; +const REGISTER_RETRY_MAX_MS = 60_000; + +let pendingFcmToken: string | null = null; +let registerRetryAttempt = 0; +let registerRetryTimer: ReturnType | null = null; +let registerFlushInFlight: Promise | null = null; + +function clearRegisterRetryTimer(): void { + if (registerRetryTimer != null) { + clearTimeout(registerRetryTimer); + registerRetryTimer = null; + } +} + +function scheduleDeferredRegistrationRetry(): void { + if (!pendingFcmToken || registerRetryTimer != null) { + return; + } + if (registerRetryAttempt >= MAX_REGISTER_RETRY_ATTEMPTS) { + logger.warn( + "[notificationAuthLifecycle] Stopped retrying deferred FCM registration (max attempts)", + ); + return; + } + + const delay = Math.min( + REGISTER_RETRY_BASE_MS * 2 ** registerRetryAttempt, + REGISTER_RETRY_MAX_MS, + ); + registerRetryAttempt += 1; + registerRetryTimer = setTimeout(() => { + registerRetryTimer = null; + void flushDeferredFcmRegistration("scheduled retry"); + }, delay); +} + +/** + * Queue FCM token registration until Bearer auth is available; retries with backoff. + */ +export function deferFcmRegistration(token: string): void { + const trimmed = token.trim(); + if (!trimmed) { + return; + } + pendingFcmToken = trimmed; + logWaitingForAuthBeforeRegistration(); + scheduleDeferredRegistrationRetry(); +} + +/** Attempt pending FCM registration when auth may now be ready (identity, resume, fetcher config). */ +export async function flushDeferredFcmRegistration( + reason?: string, +): Promise { + if (!pendingFcmToken) { + return; + } + if (registerFlushInFlight) { + return registerFlushInFlight; + } + + registerFlushInFlight = (async () => { + const token = pendingFcmToken; + if (!token) { + return; + } + + const auth = await getNotificationApiHeaders(); + if (!auth.ok) { + scheduleDeferredRegistrationRetry(); + return; + } + + clearRegisterRetryTimer(); + registerRetryAttempt = 0; + pendingFcmToken = null; + + try { + await registerToken(token); + } catch (err) { + pendingFcmToken = token; + logger.warn( + `[notificationAuthLifecycle] Deferred FCM registration failed${reason ? ` (${reason})` : ""}`, + err, + ); + scheduleDeferredRegistrationRetry(); + } + })().finally(() => { + registerFlushInFlight = null; + }); + + return registerFlushInFlight; +} + +/** + * Call when active identity or session may have become available (additive hooks only). + */ +export function onNotificationAuthMayBeReady(): void { + registerRetryAttempt = 0; + clearRegisterRetryTimer(); + void flushDeferredFcmRegistration("auth may be ready"); +} diff --git a/src/services/platforms/BaseDatabaseService.ts b/src/services/platforms/BaseDatabaseService.ts index 9f995c13..7a537e19 100644 --- a/src/services/platforms/BaseDatabaseService.ts +++ b/src/services/platforms/BaseDatabaseService.ts @@ -139,6 +139,12 @@ export abstract class BaseDatabaseService { "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", [did], ); + if (did?.trim()) { + const { onNotificationAuthMayBeReady } = await import( + "@/services/notifications/notificationAuthLifecycle" + ); + onNotificationAuthMayBeReady(); + } } /** diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 9985a961..8e65df92 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -231,6 +231,12 @@ export const PlatformServiceMixin = { logger.debug( `[PlatformServiceMixin] ActiveDid updated in active_identity table: ${newDid}`, ); + if (newDid) { + const { onNotificationAuthMayBeReady } = await import( + "@/services/notifications/notificationAuthLifecycle" + ); + onNotificationAuthMayBeReady(); + } } catch (error) { logger.error( `[PlatformServiceMixin] Error updating activeDid in active_identity table ${newDid}:`,