diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx index 952ee380e8..5014468b97 100644 --- a/__tests__/components/notifications/NotificationsContext.test.tsx +++ b/__tests__/components/notifications/NotificationsContext.test.tsx @@ -116,6 +116,7 @@ describe("NotificationsContext initialization", () => { PushNotifications.addListener.mockClear(); PushNotifications.register.mockClear(); sentry.captureException.mockClear(); + sentry.addBreadcrumb.mockClear(); }); it("does not initialize when isActive is false", async () => { @@ -185,6 +186,207 @@ describe("NotificationsContext initialization", () => { }); }); +describe("push registration behavior", () => { + const setupRegistrationCallback = async () => { + const { PushNotifications } = require("@capacitor/push-notifications"); + + let registrationCallback: + | ((token: { value: string }) => Promise) + | null = null; + + PushNotifications.addListener.mockImplementation( + (event: string, callback: (arg: unknown) => Promise) => { + if (event === "registration") { + registrationCallback = callback as (token: { + value: string; + }) => Promise; + } + return Promise.resolve(); + } + ); + + renderHook(() => useNotificationsContext(), { wrapper }); + + await waitFor(() => { + expect(PushNotifications.addListener).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(registrationCallback).not.toBeNull(); + }); + + return { + registrationCallback: registrationCallback as (token: { + value: string; + }) => Promise, + }; + }; + + beforeEach(() => { + const { PushNotifications } = require("@capacitor/push-notifications"); + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + + jest.clearAllMocks(); + PushNotifications.addListener.mockClear(); + commonApiPost.mockReset(); + commonApiPost.mockResolvedValue({}); + sentry.captureException.mockClear(); + sentry.addBreadcrumb.mockClear(); + }); + + it("retries on rate limit and does not capture exception", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitError = new Error("Rate limit exceeded. Try again in 1 sec"); + + commonApiPost.mockRejectedValue(rateLimitError); + const { registrationCallback } = await setupRegistrationCallback(); + + const setTimeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation((( + handler: TimerHandler + ) => { + if (typeof handler === "function") { + handler(); + } + return 0 as unknown as NodeJS.Timeout; + }) as typeof global.setTimeout); + + try { + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + } finally { + setTimeoutSpy.mockRestore(); + } + + expect(commonApiPost).toHaveBeenCalledTimes(3); + expect(sentry.captureException).not.toHaveBeenCalled(); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration rate limited.", + }) + ); + }); + + it("parses milliseconds retry hints as milliseconds", async () => { + const { PushNotifications } = require("@capacitor/push-notifications"); + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitError = new Error( + "Rate limit exceeded. Try again in 500 milliseconds" + ); + + PushNotifications.requestPermissions.mockResolvedValueOnce({ + receive: "denied", + }); + commonApiPost + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({}); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(2); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration attempt failed. Retrying.", + data: expect.objectContaining({ + delay_ms: 500, + rate_limited: true, + }), + }) + ); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + + it("uses retry-after header metadata from structured API errors", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitHeaders = new Headers({ "Retry-After": "2" }); + const rateLimitError = Object.assign(new Error("Too Many Requests"), { + status: 429, + headers: rateLimitHeaders, + response: { + status: 429, + headers: rateLimitHeaders, + }, + }); + + commonApiPost + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({}); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(2); + expect(commonApiPost).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ errorMode: "structured" }) + ); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration attempt failed. Retrying.", + data: expect.objectContaining({ + delay_ms: 2000, + status_code: 429, + rate_limited: true, + }), + }) + ); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + + it("skips duplicate registration for identical fingerprint", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(1); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration skipped (already registered in session).", + }) + ); + }); + + it("captures non-rate-limit push registration errors", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const fatalError = new Error("fatal push registration failure"); + commonApiPost.mockRejectedValue(fatalError); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(1); + expect(sentry.captureException).toHaveBeenCalledWith( + fatalError, + expect.objectContaining({ + tags: expect.objectContaining({ + component: "NotificationsProvider", + operation: "registerPushNotification", + }), + }) + ); + }); +}); + it("removes notifications when functions called", async () => { const { PushNotifications } = require("@capacitor/push-notifications"); diff --git a/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx b/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx index d92ea17bbb..fc23406719 100644 --- a/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx @@ -1,26 +1,27 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import { WaveDropsReverseContainer } from '@/components/waves/drops/WaveDropsReverseContainer'; -import { useIntersectionObserver } from '@/hooks/scroll/useIntersectionObserver'; -import { editSlice } from '@/store/editSlice'; +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { WaveDropsReverseContainer } from "@/components/waves/drops/WaveDropsReverseContainer"; +import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver"; +import { editSlice } from "@/store/editSlice"; -jest.mock('@/hooks/scroll/useIntersectionObserver'); +jest.mock("@/hooks/scroll/useIntersectionObserver"); const mockUseIntersectionObserver = useIntersectionObserver as jest.Mock; -const createTestStore = () => configureStore({ - reducer: { - edit: editSlice.reducer, - }, -}); +const createTestStore = () => + configureStore({ + reducer: { + edit: editSlice.reducer, + }, + }); function setup(props?: any) { const onTopIntersection = jest.fn(); const onUserScroll = jest.fn(); const store = createTestStore(); - + const utils = render( { +describe("WaveDropsReverseContainer", () => { beforeEach(() => { mockUseIntersectionObserver.mockImplementation((ref, opts, cb) => { cb({ isIntersecting: true } as any); }); - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => { - cb(); - return 1 as any; - }); + jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation((cb: any) => { + cb(); + return 1 as any; + }); }); - it('calls onTopIntersection when sentinel visible', () => { + it("calls onTopIntersection when sentinel visible", () => { const { onTopIntersection } = setup(); expect(onTopIntersection).toHaveBeenCalled(); }); - it('invokes onUserScroll on scroll', () => { + it("invokes onUserScroll on scroll", () => { const { container, onUserScroll } = setup(); const scrollDiv = container.firstChild as HTMLElement; - Object.defineProperty(scrollDiv, 'scrollTop', { value: -10, writable: true }); + Object.defineProperty(scrollDiv, "scrollTop", { + value: -10, + writable: true, + }); fireEvent.scroll(scrollDiv); - expect(onUserScroll).toHaveBeenCalledWith('up', false); + expect(onUserScroll).toHaveBeenCalledWith("up", false); + }); + + it("keeps callback ref stable across rerenders and only detaches on unmount", () => { + const store = createTestStore(); + const onTopIntersection = jest.fn(); + const callbackRef = jest.fn(); + + const { rerender, unmount } = render( + + +
child
+
+
+ ); + + const attachedCalls = () => + callbackRef.mock.calls.filter(([instance]) => instance !== null); + const detachedCalls = () => + callbackRef.mock.calls.filter(([instance]) => instance === null); + + expect(attachedCalls()).toHaveLength(1); + expect(detachedCalls()).toHaveLength(0); + + rerender( + + +
child
+
+
+ ); + + rerender( + + +
child
+
+
+ ); + + expect(attachedCalls()).toHaveLength(1); + expect(detachedCalls()).toHaveLength(0); + + unmount(); + + expect(detachedCalls()).toHaveLength(1); }); }); diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 3556bbd860..971f7d91e6 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -34,7 +34,6 @@ describe("commonApiFetch", () => { "x-6529-auth": "s", Authorization: "Bearer jwt", }), - signal: undefined, }) ); expect(result).toEqual({ result: 1 }); @@ -47,7 +46,7 @@ describe("commonApiFetch", () => { ok: false, status: 400, statusText: "Bad", - json: async () => ({ error: "err" }), + text: async () => JSON.stringify({ error: "err" }), }); await expect(commonApiFetch({ endpoint: "bad" })).rejects.toBe("err"); @@ -55,7 +54,6 @@ describe("commonApiFetch", () => { "https://api.test.6529.io/api/bad", expect.objectContaining({ headers: {}, - signal: undefined, }) ); }); @@ -100,7 +98,7 @@ describe("commonApiPost", () => { ok: false, status: 400, statusText: "B", - json: async () => ({ error: "err" }), + text: async () => JSON.stringify({ error: "err" }), }); await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( @@ -117,4 +115,95 @@ describe("commonApiPost", () => { }) ); }); + + it("rejects with structured metadata when requested", async () => { + (getStagingAuth as jest.Mock).mockReturnValue(null); + (getAuthJwt as jest.Mock).mockReturnValue(null); + const responseHeaders = new Headers({ "retry-after": "2" }); + fetchMock.mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + headers: responseHeaders, + text: async () => JSON.stringify({ error: "rate limited" }), + }); + + let error: { + message: string; + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; + }; + } + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe("rate limited"); + expect(error?.status).toBe(429); + expect(error?.headers).toBe(responseHeaders); + expect(error?.headers.get("retry-after")).toBe("2"); + expect(error?.response.status).toBe(429); + expect(error?.response.headers).toBe(responseHeaders); + expect(error?.response.body).toBe('{"error":"rate limited"}'); + }); + + it("prefers message when error key is missing", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => JSON.stringify({ message: "from message field" }), + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "from message field" + ); + }); + + it("falls back to raw text when body is non-json", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "plain error text", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "plain error text" + ); + }); + + it("falls back to statusText when response body is empty", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => "", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "Service Unavailable" + ); + }); }); diff --git a/components/notifications/NotificationsContext.tsx b/components/notifications/NotificationsContext.tsx index be68286446..869712e3a7 100644 --- a/components/notifications/NotificationsContext.tsx +++ b/components/notifications/NotificationsContext.tsx @@ -63,12 +63,264 @@ const MAX_RETRY_DELAY_MS = 5000; const IOS_INITIALIZATION_DELAY_MS = 500; const PROFILE_SWITCH_SETTLE_TIMEOUT_MS = 3000; const PROFILE_SWITCH_POLL_INTERVAL_MS = 50; +const PUSH_REGISTRATION_TOTAL_ATTEMPTS = 3; +const PUSH_REGISTRATION_BASE_DELAY_MS = 500; +const PUSH_REGISTRATION_MAX_DELAY_MS = 4000; +const PUSH_REGISTRATION_JITTER_FACTOR = 0.2; +const PUSH_REGISTRATION_MAX_RETRY_AFTER_MS = 10000; const DELEGATE_ERROR_PATTERNS = [ "capacitorDidRegisterForRemoteNotifications", "didRegisterForRemoteNotifications", ]; +const PUSH_REGISTRATION_RETRY_MESSAGE_PATTERN = + /(?:retry[-\s]?after|try again in)\s*(\d+)\s*(millisecond|milliseconds|ms|second|seconds|sec|s|minute|minutes|min|m)?/i; +const RATE_LIMIT_ERROR_PATTERNS = ["rate limit", "too many requests", "429"]; +const TRANSIENT_ERROR_PATTERNS = [ + "failed to fetch", + "load failed", + "network request failed", + "network error", + "timeout", +]; + +type PushRegistrationFingerprint = { + deviceId: string; + token: string; + profileId: string | null; +}; + +const toErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error) { + const typedError = error as { + message?: unknown; + error?: unknown; + }; + if (typeof typedError.message === "string") { + return typedError.message; + } + if (typeof typedError.error === "string") { + return typedError.error; + } + } + return String(error); +}; + +const parseStatusCode = (status: unknown): number | null => { + if (typeof status === "number" && Number.isFinite(status)) { + return status; + } + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +}; + +const extractErrorStatusCode = (error: unknown): number | null => { + if (!error || typeof error !== "object") { + return null; + } + const typedError = error as { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + cause?: { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + }; + }; + + return ( + parseStatusCode(typedError.status) ?? + parseStatusCode(typedError.response?.status) ?? + parseStatusCode(typedError.code) ?? + parseStatusCode(typedError.cause?.status) ?? + parseStatusCode(typedError.cause?.response?.status) ?? + parseStatusCode(typedError.cause?.code) + ); +}; + +const parseRetryAfterHeaderValue = (value: string): number | null => { + const seconds = Number.parseFloat(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.round(seconds * 1000); + } + + const retryAt = Date.parse(value); + if (!Number.isNaN(retryAt)) { + return Math.max(0, retryAt - Date.now()); + } + + return null; +}; + +const toRecord = (value: unknown): Record | null => { + if (!value || typeof value !== "object") { + return null; + } + return value as Record; +}; + +const extractRetryAfterFromHeaders = (headers: unknown): number | null => { + if (headers instanceof Headers) { + const retryAfter = headers.get("retry-after"); + return retryAfter ? parseRetryAfterHeaderValue(retryAfter) : null; + } + + const typedHeaders = toRecord(headers); + if (!typedHeaders) { + return null; + } + + const retryAfter = + typedHeaders["retry-after"] ?? typedHeaders["Retry-After"] ?? null; + if (typeof retryAfter === "string") { + return parseRetryAfterHeaderValue(retryAfter); + } + + return null; +}; + +const extractRetryAfterMs = (error: unknown): number | null => { + if (error && typeof error === "object") { + const typedError = error as { + headers?: unknown; + response?: { + headers?: unknown; + }; + cause?: { + headers?: unknown; + response?: { + headers?: unknown; + }; + }; + }; + + const retryAfterFromHeaders = + extractRetryAfterFromHeaders(typedError.response?.headers) ?? + extractRetryAfterFromHeaders(typedError.headers) ?? + extractRetryAfterFromHeaders(typedError.cause?.response?.headers) ?? + extractRetryAfterFromHeaders(typedError.cause?.headers); + if (retryAfterFromHeaders !== null) { + return Math.min( + retryAfterFromHeaders, + PUSH_REGISTRATION_MAX_RETRY_AFTER_MS + ); + } + } + + const message = toErrorMessage(error); + const match = PUSH_REGISTRATION_RETRY_MESSAGE_PATTERN.exec(message); + if (!match) { + return null; + } + + const value = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(value) || value < 0) { + return null; + } + + const unit = (match[2] ?? "seconds").toLowerCase(); + let multiplier = 1000; + if (unit === "ms" || unit === "millisecond" || unit === "milliseconds") { + multiplier = 1; + } else if ( + unit === "m" || + unit === "min" || + unit === "minute" || + unit === "minutes" + ) { + multiplier = 60_000; + } + + return Math.min(value * multiplier, PUSH_REGISTRATION_MAX_RETRY_AFTER_MS); +}; + +const isRateLimitError = (error: unknown): boolean => { + if (extractErrorStatusCode(error) === 429) { + return true; + } + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return RATE_LIMIT_ERROR_PATTERNS.some((pattern) => + normalizedMessage.includes(pattern) + ); +}; + +const isTransientPushRegistrationError = (error: unknown): boolean => { + if (isRateLimitError(error)) { + return true; + } + + const statusCode = extractErrorStatusCode(error); + if (statusCode !== null) { + return statusCode === 408 || (statusCode >= 500 && statusCode < 600); + } + + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return TRANSIENT_ERROR_PATTERNS.some((pattern) => + normalizedMessage.includes(pattern) + ); +}; + +const computePushRegistrationRetryDelayMs = ( + attempt: number, + retryAfterMs: number | null +): number => { + if (retryAfterMs !== null) { + return Math.max(0, retryAfterMs); + } + + const baseDelay = Math.min( + PUSH_REGISTRATION_BASE_DELAY_MS * Math.pow(2, attempt), + PUSH_REGISTRATION_MAX_DELAY_MS + ); + const jitterMultiplier = + 1 + (Math.random() * 2 - 1) * PUSH_REGISTRATION_JITTER_FACTOR; + return Math.max(0, Math.round(baseDelay * jitterMultiplier)); +}; + +const toCaptureExceptionInput = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + return new Error(toErrorMessage(error)); +}; + +const createPushRegistrationFingerprint = ({ + deviceId, + token, + profile, +}: { + deviceId: string; + token: string; + profile?: ApiIdentity; +}): PushRegistrationFingerprint => ({ + deviceId, + token, + profileId: profile?.id ?? null, +}); + +const isSamePushRegistrationFingerprint = ( + left: PushRegistrationFingerprint, + right: PushRegistrationFingerprint +): boolean => + left.deviceId === right.deviceId && + left.token === right.token && + left.profileId === right.profileId; + const isDelegateError = (error: unknown): boolean => { const errorMessage = error instanceof Error ? error.message : String(error); return DELEGATE_ERROR_PATTERNS.some((pattern) => @@ -147,6 +399,9 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ const router = useRouter(); const initializationRef = useRef(null); const isRegisteredRef = useRef(false); + const lastSuccessfulRegistrationRef = + useRef(null); + const inFlightRegistrationRef = useRef | null>(null); const connectedProfileRef = useRef(connectedProfile); const connectedAccountsRef = useRef(connectedAccounts); const activeAddressRef = useRef(address); @@ -356,6 +611,212 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ ] ); + const registerPushNotificationWithRetry = useCallback( + async ( + deviceId: string, + deviceInfo: DeviceInfo, + token: string, + profile?: ApiIdentity + ): Promise => { + const profileId = profile?.id ?? null; + + for ( + let attempt = 0; + attempt < PUSH_REGISTRATION_TOTAL_ATTEMPTS; + attempt++ + ) { + try { + await commonApiPost({ + endpoint: `push-notifications/register`, + body: { + device_id: deviceId, + token, + platform: deviceInfo.platform, + profile_id: profile?.id, + }, + errorMode: "structured", + }); + + return true; + } catch (error) { + const attemptNumber = attempt + 1; + const statusCode = extractErrorStatusCode(error); + const rateLimited = isRateLimitError(error); + const retryAfterMs = extractRetryAfterMs(error); + const hasRetriesLeft = + attemptNumber < PUSH_REGISTRATION_TOTAL_ATTEMPTS; + const shouldRetry = + hasRetriesLeft && isTransientPushRegistrationError(error); + + if (shouldRetry) { + const delayMs = computePushRegistrationRetryDelayMs( + attempt, + retryAfterMs + ); + + console.warn("Push registration attempt failed. Retrying...", { + attempt: attemptNumber, + maxAttempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delayMs, + rateLimited, + statusCode, + profileId, + }); + Sentry.addBreadcrumb({ + category: "notifications", + level: "warning", + message: "Push registration attempt failed. Retrying.", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delay_ms: delayMs, + rate_limited: rateLimited, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + + if (rateLimited) { + console.warn("Push registration rate limited", { + attempt: attemptNumber, + maxAttempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + statusCode, + profileId, + }); + Sentry.addBreadcrumb({ + category: "notifications", + level: "warning", + message: "Push registration rate limited.", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delay_ms: retryAfterMs ?? undefined, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return false; + } + + console.error("Push registration error", error); + Sentry.captureException(toCaptureExceptionInput(error), { + tags: { + component: "NotificationsProvider", + operation: "registerPushNotification", + }, + extra: { + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return false; + } + } + + return false; + }, + [] + ); + + const handlePushRegistration = useCallback( + async ( + deviceId: string, + deviceInfo: DeviceInfo, + token: string, + profile?: ApiIdentity + ): Promise => { + const fingerprintInput: { + deviceId: string; + token: string; + profile?: ApiIdentity; + } = { + deviceId, + token, + }; + if (profile !== undefined) { + fingerprintInput.profile = profile; + } + + const fingerprint = createPushRegistrationFingerprint(fingerprintInput); + const previousSuccess = lastSuccessfulRegistrationRef.current; + + if ( + previousSuccess && + isSamePushRegistrationFingerprint(previousSuccess, fingerprint) + ) { + Sentry.addBreadcrumb({ + category: "notifications", + level: "info", + message: "Push registration skipped (already registered in session).", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + profile_id: fingerprint.profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return; + } + + if (inFlightRegistrationRef.current) { + await inFlightRegistrationRef.current; + const latestSuccess = lastSuccessfulRegistrationRef.current; + if ( + latestSuccess && + isSamePushRegistrationFingerprint(latestSuccess, fingerprint) + ) { + Sentry.addBreadcrumb({ + category: "notifications", + level: "info", + message: + "Push registration skipped (already handled by in-flight registration).", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + profile_id: fingerprint.profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return; + } + } + + const registrationTask = (async () => { + const didRegister = await registerPushNotificationWithRetry( + deviceId, + deviceInfo, + token, + profile + ); + if (didRegister) { + lastSuccessfulRegistrationRef.current = fingerprint; + } + })(); + + inFlightRegistrationRef.current = registrationTask; + try { + await registrationTask; + } finally { + if (inFlightRegistrationRef.current === registrationTask) { + inFlightRegistrationRef.current = null; + } + } + }, + [registerPushNotificationWithRetry] + ); + const initializePushNotifications = useCallback( async (profile?: ApiIdentity) => { try { @@ -368,7 +829,7 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ await PushNotifications.addListener("registration", async (token) => { isRegisteredRef.current = true; - await registerPushNotification( + await handlePushRegistration( stableDeviceId, deviceInfo, token.value, @@ -418,7 +879,7 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ throw error; } }, - [isIos, router, handlePushNotificationAction] + [isIos, router, handlePushNotificationAction, handlePushRegistration] ); const initializeNotifications = useCallback( @@ -502,33 +963,6 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ ); }; -const registerPushNotification = async ( - deviceId: string, - deviceInfo: DeviceInfo, - token: string, - profile?: ApiIdentity -) => { - try { - await commonApiPost({ - endpoint: `push-notifications/register`, - body: { - device_id: deviceId, - token, - platform: deviceInfo.platform, - profile_id: profile?.id, - }, - }); - } catch (error) { - console.error("Push registration error", error); - Sentry.captureException(error, { - tags: { - component: "NotificationsProvider", - operation: "registerPushNotification", - }, - }); - } -}; - const resolveRedirectUrl = (notificationData: DevicePushData) => { const { redirect, ...params } = notificationData; diff --git a/components/waves/drops/WaveDropsReverseContainer.tsx b/components/waves/drops/WaveDropsReverseContainer.tsx index 62b17534c0..1f48cad4af 100644 --- a/components/waves/drops/WaveDropsReverseContainer.tsx +++ b/components/waves/drops/WaveDropsReverseContainer.tsx @@ -88,7 +88,7 @@ export const WaveDropsReverseContainer = forwardRef< }; }, []); - React.useImperativeHandle(ref, () => scrollContainerRef.current!); + React.useImperativeHandle(ref, () => scrollContainerRef.current!, []); return (
( return event; } +function isServerActionNotFoundError(event: Event): boolean { + const value = event.exception?.values?.[0]?.value; + const message = typeof value === "string" ? value : event.message; + return ( + typeof message === "string" && message.includes(SERVER_ACTION_NOT_FOUND) + ); +} + +function isProbeLikeRequest( + url: string, + securityProbeTag: string | undefined +): boolean { + if (securityProbeTag === "true") { + return true; + } + + return ( + PROBE_PATTERNS.some((pattern) => url.includes(pattern)) || + url.includes("jquery-file-upload") + ); +} + +function getStringTagValue(event: Event, key: string): string | undefined { + const value = event.tags?.[key]; + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getHeaderValue( + headers: unknown, + headerName: string +): string | undefined { + const normalizedTarget = headerName.toLowerCase(); + + if (Array.isArray(headers)) { + for (const entry of headers) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const key = entry[0]; + const value = entry[1]; + if ( + typeof key === "string" && + key.toLowerCase() === normalizedTarget && + typeof value === "string" + ) { + return value; + } + } + return undefined; + } + + if (!isRecord(headers)) { + return undefined; + } + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== normalizedTarget) { + continue; + } + return typeof value === "string" ? value : undefined; + } + + return undefined; +} + +function getRequestContentType(event: Event): string { + const headers = (event.request as { headers?: unknown } | undefined)?.headers; + const fromHeader = getHeaderValue(headers, "content-type"); + if (typeof fromHeader === "string") { + return fromHeader.toLowerCase(); + } + + const req = event.request as + | ({ inferred_content_type?: unknown } & Record) + | undefined; + const inferred = req?.["inferred_content_type"]; + return typeof inferred === "string" ? inferred.toLowerCase() : ""; +} + +function getNextActionHeader(event: Event): string | undefined { + const headers = (event.request as { headers?: unknown } | undefined)?.headers; + const value = getHeaderValue(headers, "next-action"); + return typeof value === "string" ? value.trim() : undefined; +} + +function isMalformedNextActionId(nextAction: string): boolean { + const normalized = nextAction.trim().toLowerCase(); + if (!normalized) { + return true; + } + + if (normalized.length < MIN_NEXT_ACTION_LENGTH) { + return true; + } + + if (normalized.includes(" ") || normalized.includes("----")) { + return true; + } + + return MALFORMED_NEXT_ACTION_PATTERNS.some((pattern) => + normalized.includes(pattern) + ); +} + +function isMalformedNextActionProbe(event: Event): boolean { + const nextAction = getNextActionHeader(event); + if (!nextAction || !isMalformedNextActionId(nextAction)) { + return false; + } + + const method = (event.request?.method || "").toUpperCase(); + const contentType = getRequestContentType(event); + + return method === "POST" && contentType.includes("text/plain"); +} + +function hasServerActionProbeSignature(event: Event): boolean { + const value = event.exception?.values?.[0]; + const message = + (typeof value?.value === "string" && value.value) || + (typeof event.message === "string" ? event.message : ""); + + if (!message) { + return false; + } + + return ( + message.includes(SERVER_ACTION_NOT_FOUND) || + message.includes(NEXTJS_RSC_TEXT_PLAIN_INVARIANT) + ); +} + +function isProbeLikeServerActionRequest(event: Event): boolean { + const url = (event.request?.url || "").toLowerCase(); + if (isMalformedNextActionProbe(event)) { + return true; + } + return isProbeLikeRequest(url, getStringTagValue(event, "security_probe")); +} + +function isWebStreamsTransformAlgorithmError(event: Event): boolean { + const value = event.exception?.values?.[0]; + const message = + typeof value?.value === "string" ? value.value : event.message || ""; + + if (value?.type && value.type !== "TypeError") { + return false; + } + + return ( + typeof message === "string" && + message.includes(WEBSTREAMS_TRANSFORM_ALGORITHM_ERROR) + ); +} + +function isProbeLikeWebStreamsRequest(event: Event): boolean { + const url = (event.request?.url || "").toLowerCase(); + return isProbeLikeRequest(url, getStringTagValue(event, "security_probe")); +} + +export function filterServerActionProbeErrors( + event: T +): T | null { + if (!isServerActionNotFoundError(event)) { + return event; + } + + if (isProbeLikeServerActionRequest(event)) { + return null; + } + + return event; +} + +export function filterMalformedNextActionProbeErrors( + event: T +): T | null { + if (!hasServerActionProbeSignature(event)) { + return event; + } + + if (isMalformedNextActionProbe(event)) { + return null; + } + + return event; +} + +export function filterWebStreamsProbeErrors( + event: T +): T | null { + if (!isWebStreamsTransformAlgorithmError(event)) { + return event; + } + + if (isProbeLikeWebStreamsRequest(event)) { + return null; + } + + return event; +} + export function tagSecurityProbes(event: T): T { try { const url = (event?.request?.url || "").toLowerCase(); - if (PROBE_PATTERNS.some((p) => url.includes(p))) { + if ( + PROBE_PATTERNS.some((p) => url.includes(p)) || + isMalformedNextActionProbe(event) + ) { event.level = "info"; event.tags = event.tags ? { ...event.tags, ...probeTags } diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index a5a5f7cd18..df66245099 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -6,7 +6,10 @@ import { publicEnv } from "@/config/env"; import { + filterMalformedNextActionProbeErrors, + filterServerActionProbeErrors, filterTunnelRouteErrors, + filterWebStreamsProbeErrors, tagSecurityProbes, } from "@/config/sentryProbes"; import { @@ -39,11 +42,31 @@ Sentry.init({ // Handle obvious bot / exploit probes more gently (edge) // ------------------------------------------------------------ beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) { - const filtered = filterTunnelRouteErrors(event, hint); - if (filtered === null) { + const tunnelFiltered = filterTunnelRouteErrors(event, hint); + if (tunnelFiltered === null) { return null; } - return sanitizeSentryEvent(tagSecurityProbes(filtered)); + + const tagged = tagSecurityProbes(tunnelFiltered); + const actionFiltered = filterServerActionProbeErrors(tagged); + if (actionFiltered === null) { + return null; + } + + const malformedNextActionFiltered = + filterMalformedNextActionProbeErrors(actionFiltered); + if (malformedNextActionFiltered === null) { + return null; + } + + const webStreamsFiltered = filterWebStreamsProbeErrors( + malformedNextActionFiltered + ); + if (webStreamsFiltered === null) { + return null; + } + + return sanitizeSentryEvent(webStreamsFiltered); }, beforeSendTransaction(event) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 12b475ad7e..9f93ca1bfe 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,7 +4,10 @@ import { publicEnv } from "@/config/env"; import { + filterMalformedNextActionProbeErrors, + filterServerActionProbeErrors, filterTunnelRouteErrors, + filterWebStreamsProbeErrors, tagSecurityProbes, } from "@/config/sentryProbes"; import { @@ -39,11 +42,31 @@ Sentry.init({ // Handle obvious bot / exploit probes more gently // ------------------------------------------------------------ beforeSend(event, hint) { - const filtered = filterTunnelRouteErrors(event, hint); - if (filtered === null) { + const tunnelFiltered = filterTunnelRouteErrors(event, hint); + if (tunnelFiltered === null) { return null; } - return sanitizeSentryEvent(tagSecurityProbes(filtered)); + + const tagged = tagSecurityProbes(tunnelFiltered); + const actionFiltered = filterServerActionProbeErrors(tagged); + if (actionFiltered === null) { + return null; + } + + const malformedNextActionFiltered = + filterMalformedNextActionProbeErrors(actionFiltered); + if (malformedNextActionFiltered === null) { + return null; + } + + const webStreamsFiltered = filterWebStreamsProbeErrors( + malformedNextActionFiltered + ); + if (webStreamsFiltered === null) { + return null; + } + + return sanitizeSentryEvent(webStreamsFiltered); }, beforeSendTransaction(event) { diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 119bbe85ae..6fc9084ac5 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -1,6 +1,18 @@ import { publicEnv } from "@/config/env"; import { getAuthJwt, getStagingAuth } from "../auth/auth.utils"; +type ApiErrorMode = "legacy-string" | "structured"; + +type StructuredApiError = Error & { + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; +}; + const getHeaders = ( headers?: Record, contentType: boolean = true @@ -36,20 +48,84 @@ const buildUrl = ( return url; }; -const handleApiError = async (res: Response): Promise => { - let errorMessage: string; - let rawContent: string = ""; - +const normalizeHeaders = (value: unknown): Headers => { + if (value instanceof Headers) { + return value; + } try { - const body: any = await res.json(); - errorMessage = body?.error ?? res.statusText ?? "Something went wrong"; + return new Headers(value as HeadersInit); } catch { - try { - rawContent = await res.text(); - errorMessage = rawContent || res.statusText || "Something went wrong"; - } catch { - errorMessage = res.statusText || "Something went wrong"; + return new Headers(); + } +}; + +const createStructuredApiError = ({ + message, + status, + headers, + body, +}: { + message: string; + status: number; + headers: Headers; + body?: unknown; +}): StructuredApiError => { + const error = new Error(message) as StructuredApiError; + error.name = "ApiError"; + error.status = status; + error.headers = headers; + error.response = { + status, + headers, + ...(body !== undefined ? { body } : {}), + }; + return error; +}; + +const handleApiError = async ( + res: Response, + errorMode: ApiErrorMode +): Promise => { + const fallbackErrorMessage = res.statusText || "Something went wrong"; + let errorMessage = fallbackErrorMessage; + let errorBody: unknown = undefined; + + try { + const rawContent = await res.text(); + + if (rawContent) { + errorBody = rawContent; + try { + const parsedBody: unknown = JSON.parse(rawContent); + const bodyRecord = + parsedBody && typeof parsedBody === "object" + ? (parsedBody as Record) + : null; + const bodyError = bodyRecord?.["error"]; + const bodyMessage = bodyRecord?.["message"]; + + if (typeof bodyError === "string") { + errorMessage = bodyError; + } else if (typeof bodyMessage === "string") { + errorMessage = bodyMessage; + } else { + errorMessage = rawContent; + } + } catch { + errorMessage = rawContent; + } } + } catch { + errorMessage = fallbackErrorMessage; + } + + if (errorMode === "structured") { + throw createStructuredApiError({ + message: errorMessage, + status: res.status, + headers: normalizeHeaders((res as { headers?: unknown }).headers), + body: errorBody, + }); } return Promise.reject(errorMessage); @@ -61,7 +137,8 @@ const executeApiRequest = async ( headers: Record, body?: BodyInit, signal?: AbortSignal, - parseJson: boolean = true + parseJson: boolean = true, + errorMode: ApiErrorMode = "legacy-string" ): Promise => { try { const res = await fetch(url, { @@ -72,7 +149,7 @@ const executeApiRequest = async ( }); if (!res.ok) { - return handleApiError(res); + return handleApiError(res, errorMode); } if (!parseJson) { @@ -170,7 +247,7 @@ interface RetryOptions { */ export const commonApiFetchWithRetry = async < T, - U = Record + U = Record, >(param: { readonly endpoint: string; readonly headers?: Record | undefined; @@ -187,7 +264,6 @@ export const commonApiFetchWithRetry = async < let attempts = 0; let currentDelayMs = initialDelayMs; - while (true) { try { if (fetchParams.signal?.aborted) { @@ -264,6 +340,7 @@ export const commonApiPost = async >(param: { headers?: Record | undefined; params?: Z | undefined; signal?: AbortSignal | undefined; + errorMode?: ApiErrorMode | undefined; }): Promise => { const url = buildUrl( param.endpoint, @@ -275,7 +352,9 @@ export const commonApiPost = async >(param: { "POST", getHeaders(param.headers, true), JSON.stringify(param.body), - param.signal + param.signal, + true, + param.errorMode ?? "legacy-string" ); }; @@ -314,7 +393,7 @@ export const commonApiDelete = async (param: { export const commonApiDeleteWithBody = async < T, U, - Z = Record + Z = Record, >(param: { endpoint: string; body: T;