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}:`,