From 65beae0a5e6132b3dc30efa697246f6f1fa81119 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 18:11:52 +0000 Subject: [PATCH 1/9] Add random delay to password submissions Co-authored-by: Kent C. Dodds --- app/routes/login.tsx | 8 ++-- app/routes/me_.password.tsx | 5 +++ app/routes/reset-password.tsx | 6 ++- app/routes/signup.tsx | 6 +++ .../password-submission-delay.test.ts | 45 +++++++++++++++++++ app/utils/password.server.ts | 33 ++++++++++++++ 6 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 app/utils/__tests__/password-submission-delay.test.ts diff --git a/app/routes/login.tsx b/app/routes/login.tsx index e13ac62f6..16b09db68 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -33,6 +33,7 @@ import { reuseUsefulLoaderHeaders, } from '#app/utils/misc.ts' import { + applyPasswordSubmissionDelay, DUMMY_PASSWORD_HASH, verifyPassword, } from '#app/utils/password.server.ts' @@ -141,9 +142,9 @@ export async function action({ request }: Route.ActionArgs) { loginSession.flashError( 'Invalid email or password. If you do not have a password yet, use "Reset password" to set one.', ) - return redirect(`/login`, { - headers: await loginSession.getHeaders(), - }) + const headers = await loginSession.getHeaders() + await applyPasswordSubmissionDelay() + return redirect(`/login`, { headers }) } const session = await getSession(request) @@ -172,6 +173,7 @@ export async function action({ request }: Route.ActionArgs) { } catch (error) { console.error('Failed to read client session on login', error) } + await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } diff --git a/app/routes/me_.password.tsx b/app/routes/me_.password.tsx index 2a58079de..d2b7a6a17 100644 --- a/app/routes/me_.password.tsx +++ b/app/routes/me_.password.tsx @@ -6,6 +6,7 @@ import { HeaderSection } from '#app/components/sections/header-section.tsx' import { Spacer } from '#app/components/spacer.tsx' import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { + applyPasswordSubmissionDelay, getPasswordHash, getPasswordStrengthError, verifyPassword, @@ -56,6 +57,7 @@ export async function action({ request }: Route.ActionArgs) { if (existingPassword) { if (typeof currentPassword !== 'string' || !currentPassword) { + await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -69,6 +71,7 @@ export async function action({ request }: Route.ActionArgs) { hash: existingPassword.hash, }) if (!ok) { + await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -87,6 +90,7 @@ export async function action({ request }: Route.ActionArgs) { })() if (passwordError || confirmPasswordError) { + await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -116,6 +120,7 @@ export async function action({ request }: Route.ActionArgs) { const headers = new Headers() await session.getHeaders(headers) + await applyPasswordSubmissionDelay() return redirect(`/me?message=${encodeURIComponent('✅ Password updated')}`, { headers, }) diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx index 69cb102ea..cb19d9a08 100644 --- a/app/routes/reset-password.tsx +++ b/app/routes/reset-password.tsx @@ -11,8 +11,9 @@ import { ensurePrimary } from '#app/utils/litefs-js.server.ts' import { getLoginInfoSession } from '#app/utils/login.server.ts' import { createAndSendPasswordResetVerificationEmail } from '#app/utils/password-reset.server.ts' import { - getPasswordStrengthError, + applyPasswordSubmissionDelay, getPasswordHash, + getPasswordStrengthError, } from '#app/utils/password.server.ts' import { prisma } from '#app/utils/prisma.server.ts' import { getSession, getUser } from '#app/utils/session.server.ts' @@ -230,6 +231,7 @@ export async function action({ request }: Route.ActionArgs) { })() if (passwordError || confirmPasswordError) { + await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -252,6 +254,7 @@ export async function action({ request }: Route.ActionArgs) { loginSession.flashError( 'No account found for that email. Create one instead.', ) + await applyPasswordSubmissionDelay() return redirect('/signup', { headers: await loginSession.getHeaders() }) } @@ -294,6 +297,7 @@ export async function action({ request }: Route.ActionArgs) { } catch (error) { console.error('Failed to read client session on password reset', error) } + await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx index 1dd0c2b3b..0bfac8941 100644 --- a/app/routes/signup.tsx +++ b/app/routes/signup.tsx @@ -28,6 +28,7 @@ import { TEAM_SNOWBOARD_MAP, } from '#app/utils/onboarding.ts' import { + applyPasswordSubmissionDelay, getPasswordHash, getPasswordStrengthError, } from '#app/utils/password.server.ts' @@ -247,6 +248,7 @@ export async function action({ request }: Route.ActionArgs) { } if (Object.values(errors).some((e) => e !== null)) { + await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -290,6 +292,7 @@ export async function action({ request }: Route.ActionArgs) { loginInfoSession.flashMessage( 'An account already exists for that email. Log in instead (or reset your password).', ) + await applyPasswordSubmissionDelay() return redirect('/login', { headers: await loginInfoSession.getHeaders(), }) @@ -326,6 +329,7 @@ export async function action({ request }: Route.ActionArgs) { loginInfoSession.flashMessage( 'Your account was created. Please log in to continue.', ) + await applyPasswordSubmissionDelay() return redirect('/login', { headers: await loginInfoSession.getHeaders(), }) @@ -354,11 +358,13 @@ export async function action({ request }: Route.ActionArgs) { if (clientSession) await clientSession.getHeaders(headers) loginInfoSession.clean() await loginInfoSession.getHeaders(headers) + await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } catch (error: unknown) { // `ensurePrimary()` throws a Response to replay the request on the primary instance. if (isResponse(error)) throw error console.error(getErrorStack(error)) + await applyPasswordSubmissionDelay() return json( { status: 'error', diff --git a/app/utils/__tests__/password-submission-delay.test.ts b/app/utils/__tests__/password-submission-delay.test.ts new file mode 100644 index 000000000..3c759dc12 --- /dev/null +++ b/app/utils/__tests__/password-submission-delay.test.ts @@ -0,0 +1,45 @@ +import { expect, test, vi } from 'vitest' +import { + applyPasswordSubmissionDelay, + getPasswordSubmissionDelayMs, +} from '../password.server.ts' + +test('getPasswordSubmissionDelayMs clamps non-positive maxMs to 0', () => { + const randomInt = vi.fn(() => 123) + + expect(getPasswordSubmissionDelayMs({ maxMs: 0, randomInt })).toBe(0) + expect(getPasswordSubmissionDelayMs({ maxMs: -5, randomInt })).toBe(0) + + expect(randomInt).not.toHaveBeenCalled() +}) + +test('getPasswordSubmissionDelayMs uses an inclusive upper bound', () => { + const randomInt = vi.fn(() => 42) + + expect(getPasswordSubmissionDelayMs({ maxMs: 250, randomInt })).toBe(42) + expect(randomInt).toHaveBeenCalledWith(0, 251) +}) + +test('applyPasswordSubmissionDelay waits for the sampled delay', async () => { + vi.useFakeTimers() + try { + const randomInt = vi.fn(() => 40) + let resolved = false + const promise = applyPasswordSubmissionDelay({ maxMs: 250, randomInt }).then( + () => { + resolved = true + }, + ) + + await vi.advanceTimersByTimeAsync(39) + expect(resolved).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await promise + expect(resolved).toBe(true) + } finally { + vi.useRealTimers() + vi.restoreAllMocks() + } +}) + diff --git a/app/utils/password.server.ts b/app/utils/password.server.ts index a0f8bfc31..e89a13c6b 100644 --- a/app/utils/password.server.ts +++ b/app/utils/password.server.ts @@ -10,6 +10,39 @@ const PASSWORD_MIN_LENGTH = 8 // NOTE: bcrypt only uses the first 72 bytes of the password. // Enforcing this avoids giving users a false sense of security. const PASSWORD_MAX_BYTES = 72 +const PASSWORD_SUBMISSION_DELAY_MAX_MS = 250 + +type RandomIntFunction = (min: number, max: number) => number + +function normalizeMaxDelayMs(maxMs: number) { + if (!Number.isFinite(maxMs)) return 0 + return Math.max(0, Math.floor(maxMs)) +} + +/** + * Adds random jitter to password submission handling to make timing attacks + * noisier. Keep the upper bound small to avoid noticeable UX regressions. + */ +export function getPasswordSubmissionDelayMs({ + maxMs = PASSWORD_SUBMISSION_DELAY_MAX_MS, + randomInt = crypto.randomInt, +}: { + maxMs?: number + randomInt?: RandomIntFunction +} = {}) { + const safeMaxMs = normalizeMaxDelayMs(maxMs) + // `crypto.randomInt` uses an exclusive upper bound. + return safeMaxMs === 0 ? 0 : randomInt(0, safeMaxMs + 1) +} + +export async function applyPasswordSubmissionDelay(options?: { + maxMs?: number + randomInt?: RandomIntFunction +}) { + const delayMs = getPasswordSubmissionDelayMs(options) + if (delayMs <= 0) return + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} export async function getPasswordHash(password: string) { return bcrypt.hash(password, BCRYPT_COST) From 171a02bde547793a91b98b9ee0755407f2d56e8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 18:27:34 +0000 Subject: [PATCH 2/9] Call password delay once per action Co-authored-by: Kent C. Dodds --- app/routes/login.tsx | 6 ++---- app/routes/me_.password.tsx | 5 +---- app/routes/reset-password.tsx | 9 +++------ app/routes/signup.tsx | 15 ++++----------- 4 files changed, 10 insertions(+), 25 deletions(-) diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 16b09db68..c43183dea 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -109,6 +109,7 @@ export async function action({ request }: Route.ActionArgs) { } if (email) loginSession.setEmail(email) + await applyPasswordSubmissionDelay() if (!email.match(/.+@.+/)) { loginSession.flashError('A valid email is required') @@ -142,9 +143,7 @@ export async function action({ request }: Route.ActionArgs) { loginSession.flashError( 'Invalid email or password. If you do not have a password yet, use "Reset password" to set one.', ) - const headers = await loginSession.getHeaders() - await applyPasswordSubmissionDelay() - return redirect(`/login`, { headers }) + return redirect(`/login`, { headers: await loginSession.getHeaders() }) } const session = await getSession(request) @@ -173,7 +172,6 @@ export async function action({ request }: Route.ActionArgs) { } catch (error) { console.error('Failed to read client session on login', error) } - await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } diff --git a/app/routes/me_.password.tsx b/app/routes/me_.password.tsx index d2b7a6a17..c7c26bff2 100644 --- a/app/routes/me_.password.tsx +++ b/app/routes/me_.password.tsx @@ -39,6 +39,7 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const user = await requireUser(request) const formData = await request.formData() + await applyPasswordSubmissionDelay() const currentPassword = formData.get('currentPassword') const password = @@ -57,7 +58,6 @@ export async function action({ request }: Route.ActionArgs) { if (existingPassword) { if (typeof currentPassword !== 'string' || !currentPassword) { - await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -71,7 +71,6 @@ export async function action({ request }: Route.ActionArgs) { hash: existingPassword.hash, }) if (!ok) { - await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -90,7 +89,6 @@ export async function action({ request }: Route.ActionArgs) { })() if (passwordError || confirmPasswordError) { - await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -120,7 +118,6 @@ export async function action({ request }: Route.ActionArgs) { const headers = new Headers() await session.getHeaders(headers) - await applyPasswordSubmissionDelay() return redirect(`/me?message=${encodeURIComponent('✅ Password updated')}`, { headers, }) diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx index cb19d9a08..d45423506 100644 --- a/app/routes/reset-password.tsx +++ b/app/routes/reset-password.tsx @@ -216,6 +216,8 @@ export async function action({ request }: Route.ActionArgs) { }) } + await applyPasswordSubmissionDelay() + const passwordEntry = formData.get('password') const password = typeof passwordEntry === 'string' ? passwordEntry : '' const confirmPassword = @@ -231,7 +233,6 @@ export async function action({ request }: Route.ActionArgs) { })() if (passwordError || confirmPasswordError) { - await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -251,10 +252,7 @@ export async function action({ request }: Route.ActionArgs) { if (!userRecord) { loginSession.clean() - loginSession.flashError( - 'No account found for that email. Create one instead.', - ) - await applyPasswordSubmissionDelay() + loginSession.flashError('No account found for that email. Create one instead.') return redirect('/signup', { headers: await loginSession.getHeaders() }) } @@ -297,7 +295,6 @@ export async function action({ request }: Route.ActionArgs) { } catch (error) { console.error('Failed to read client session on password reset', error) } - await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx index 0bfac8941..2e4359d79 100644 --- a/app/routes/signup.tsx +++ b/app/routes/signup.tsx @@ -230,6 +230,8 @@ export async function action({ request }: Route.ActionArgs) { const password = form.get('password') const confirmPassword = form.get('confirmPassword') + await applyPasswordSubmissionDelay() + const errors: ActionData['errors'] = { firstName: getErrorForFirstName( typeof firstName === 'string' ? firstName : null, @@ -248,7 +250,6 @@ export async function action({ request }: Route.ActionArgs) { } if (Object.values(errors).some((e) => e !== null)) { - await applyPasswordSubmissionDelay() return json( { status: 'error', @@ -292,10 +293,7 @@ export async function action({ request }: Route.ActionArgs) { loginInfoSession.flashMessage( 'An account already exists for that email. Log in instead (or reset your password).', ) - await applyPasswordSubmissionDelay() - return redirect('/login', { - headers: await loginInfoSession.getHeaders(), - }) + return redirect('/login', { headers: await loginInfoSession.getHeaders() }) } throw error } @@ -329,10 +327,7 @@ export async function action({ request }: Route.ActionArgs) { loginInfoSession.flashMessage( 'Your account was created. Please log in to continue.', ) - await applyPasswordSubmissionDelay() - return redirect('/login', { - headers: await loginInfoSession.getHeaders(), - }) + return redirect('/login', { headers: await loginInfoSession.getHeaders() }) } let clientSession: Awaited> | null = @@ -358,13 +353,11 @@ export async function action({ request }: Route.ActionArgs) { if (clientSession) await clientSession.getHeaders(headers) loginInfoSession.clean() await loginInfoSession.getHeaders(headers) - await applyPasswordSubmissionDelay() return redirect('/me', { headers }) } catch (error: unknown) { // `ensurePrimary()` throws a Response to replay the request on the primary instance. if (isResponse(error)) throw error console.error(getErrorStack(error)) - await applyPasswordSubmissionDelay() return json( { status: 'error', From 4c7db1bfa9f8babaf58462b60c48535144f49fe6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 18:34:06 +0000 Subject: [PATCH 3/9] Run password delay at action start Co-authored-by: Kent C. Dodds --- app/routes/login.tsx | 2 +- app/routes/me_.password.tsx | 2 +- app/routes/reset-password.tsx | 3 +-- app/routes/signup.tsx | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/routes/login.tsx b/app/routes/login.tsx index c43183dea..28bd429e2 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -84,6 +84,7 @@ export const meta: MetaFunction = ({ } export async function action({ request }: Route.ActionArgs) { + await applyPasswordSubmissionDelay() const formData = await request.formData() const loginSession = await getLoginInfoSession(request) @@ -109,7 +110,6 @@ export async function action({ request }: Route.ActionArgs) { } if (email) loginSession.setEmail(email) - await applyPasswordSubmissionDelay() if (!email.match(/.+@.+/)) { loginSession.flashError('A valid email is required') diff --git a/app/routes/me_.password.tsx b/app/routes/me_.password.tsx index c7c26bff2..12c188622 100644 --- a/app/routes/me_.password.tsx +++ b/app/routes/me_.password.tsx @@ -37,9 +37,9 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { + await applyPasswordSubmissionDelay() const user = await requireUser(request) const formData = await request.formData() - await applyPasswordSubmissionDelay() const currentPassword = formData.get('currentPassword') const password = diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx index d45423506..e81a50501 100644 --- a/app/routes/reset-password.tsx +++ b/app/routes/reset-password.tsx @@ -105,6 +105,7 @@ export async function loader({ request }: Route.LoaderArgs) { } export async function action({ request }: Route.ActionArgs) { + await applyPasswordSubmissionDelay() const loginSession = await getLoginInfoSession(request) const formData = await request.formData() const actionId = formData.get('actionId') @@ -216,8 +217,6 @@ export async function action({ request }: Route.ActionArgs) { }) } - await applyPasswordSubmissionDelay() - const passwordEntry = formData.get('password') const password = typeof passwordEntry === 'string' ? passwordEntry : '' const confirmPassword = diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx index 2e4359d79..884467f76 100644 --- a/app/routes/signup.tsx +++ b/app/routes/signup.tsx @@ -86,6 +86,7 @@ const actionIds = { } export async function action({ request }: Route.ActionArgs) { + await applyPasswordSubmissionDelay() const loginInfoSession = await getLoginInfoSession(request) const requestText = await request.text() @@ -230,8 +231,6 @@ export async function action({ request }: Route.ActionArgs) { const password = form.get('password') const confirmPassword = form.get('confirmPassword') - await applyPasswordSubmissionDelay() - const errors: ActionData['errors'] = { firstName: getErrorForFirstName( typeof firstName === 'string' ? firstName : null, From f639601b3dc95586ff4538ea434d8a9241367851 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 19:07:25 +0000 Subject: [PATCH 4/9] Test zero-delay password submission Co-authored-by: Kent C. Dodds --- .../__tests__/password-submission-delay.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/utils/__tests__/password-submission-delay.test.ts b/app/utils/__tests__/password-submission-delay.test.ts index 3c759dc12..e49708e84 100644 --- a/app/utils/__tests__/password-submission-delay.test.ts +++ b/app/utils/__tests__/password-submission-delay.test.ts @@ -20,6 +20,21 @@ test('getPasswordSubmissionDelayMs uses an inclusive upper bound', () => { expect(randomInt).toHaveBeenCalledWith(0, 251) }) +test('applyPasswordSubmissionDelay resolves immediately when maxMs is 0', async () => { + vi.useFakeTimers() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + setTimeoutSpy.mockClear() + try { + const randomInt = vi.fn() + await applyPasswordSubmissionDelay({ maxMs: 0, randomInt }) + expect(randomInt).not.toHaveBeenCalled() + expect(setTimeoutSpy).not.toHaveBeenCalled() + } finally { + setTimeoutSpy.mockRestore() + vi.useRealTimers() + } +}) + test('applyPasswordSubmissionDelay waits for the sampled delay', async () => { vi.useFakeTimers() try { @@ -39,7 +54,6 @@ test('applyPasswordSubmissionDelay waits for the sampled delay', async () => { expect(resolved).toBe(true) } finally { vi.useRealTimers() - vi.restoreAllMocks() } }) From 8061ef446dafe17cfe7ba420c3cce8c701b8f702 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 19:15:41 +0000 Subject: [PATCH 5/9] Clamp password delay max to crypto.randomInt limits Co-authored-by: Kent C. Dodds --- .../password-submission-delay.test.ts | 20 +++++++++++++++++++ app/utils/password.server.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/utils/__tests__/password-submission-delay.test.ts b/app/utils/__tests__/password-submission-delay.test.ts index e49708e84..bdc77ca21 100644 --- a/app/utils/__tests__/password-submission-delay.test.ts +++ b/app/utils/__tests__/password-submission-delay.test.ts @@ -13,6 +13,26 @@ test('getPasswordSubmissionDelayMs clamps non-positive maxMs to 0', () => { expect(randomInt).not.toHaveBeenCalled() }) +test('getPasswordSubmissionDelayMs treats non-finite maxMs as 0', () => { + const randomInt = vi.fn(() => 123) + + expect(getPasswordSubmissionDelayMs({ maxMs: Number.NaN, randomInt })).toBe(0) + expect( + getPasswordSubmissionDelayMs({ + maxMs: Number.POSITIVE_INFINITY, + randomInt, + }), + ).toBe(0) + + expect(randomInt).not.toHaveBeenCalled() +}) + +test('getPasswordSubmissionDelayMs caps maxMs to crypto.randomInt range', () => { + const randomInt = vi.fn(() => 0) + void getPasswordSubmissionDelayMs({ maxMs: 2 ** 48, randomInt }) + expect(randomInt).toHaveBeenCalledWith(0, 2 ** 48 - 1) +}) + test('getPasswordSubmissionDelayMs uses an inclusive upper bound', () => { const randomInt = vi.fn(() => 42) diff --git a/app/utils/password.server.ts b/app/utils/password.server.ts index e89a13c6b..ed7b17a84 100644 --- a/app/utils/password.server.ts +++ b/app/utils/password.server.ts @@ -11,12 +11,19 @@ const PASSWORD_MIN_LENGTH = 8 // Enforcing this avoids giving users a false sense of security. const PASSWORD_MAX_BYTES = 72 const PASSWORD_SUBMISSION_DELAY_MAX_MS = 250 +// `crypto.randomInt` requires (max - min) < 2**48 and both args are safe ints. +// We call `randomInt(0, safeMaxMs + 1)` (exclusive upper bound), so cap `safeMaxMs` +// to keep the exclusive max within range. +const MAX_PASSWORD_SUBMISSION_DELAY_MS = 2 ** 48 - 2 type RandomIntFunction = (min: number, max: number) => number function normalizeMaxDelayMs(maxMs: number) { if (!Number.isFinite(maxMs)) return 0 - return Math.max(0, Math.floor(maxMs)) + return Math.min( + MAX_PASSWORD_SUBMISSION_DELAY_MS, + Math.max(0, Math.floor(maxMs)), + ) } /** From 2fd66f1cb36473b550a53f14a5b8e8eb1a7f9d79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 20:57:21 +0000 Subject: [PATCH 6/9] CI: fallback ffmpeg install for Playwright Co-authored-by: Kent C. Dodds --- .github/workflows/deployment.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index e7b5f3e08..7a1c1954f 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -140,8 +140,21 @@ jobs: node-version: 24 - name: 🎬 Setup ffmpeg + continue-on-error: true uses: FedericoCarboni/setup-ffmpeg@v3 + - name: 🎬 Ensure ffmpeg installed + run: | + if command -v ffmpeg >/dev/null 2>&1; then + ffmpeg -version + exit 0 + fi + + # Fallback: if the setup action flakes (ex: fetch fails), install via apt. + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get -o Acquire::Retries=3 install -y ffmpeg + ffmpeg -version + - name: 📥 Download deps uses: bahmutov/npm-install@v1.11.2 with: From d5b8f59bedd057831851eede145e2182ad42fd08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 21:29:56 +0000 Subject: [PATCH 7/9] Skip external fetches in mocks mode Co-authored-by: Kent C. Dodds --- app/utils/password.server.ts | 4 ++++ app/utils/user-info.server.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/app/utils/password.server.ts b/app/utils/password.server.ts index ed7b17a84..8b1946439 100644 --- a/app/utils/password.server.ts +++ b/app/utils/password.server.ts @@ -75,6 +75,10 @@ function getPasswordHashParts(password: string) { } async function checkIsCommonPassword(password: string) { + // In mocks mode we don't want to make external HTTP requests for password + // strength checks (it can hang CI / e2e test runs and adds nondeterminism). + if (process.env.MOCKS === 'true') return false + const [prefix, suffix] = getPasswordHashParts(password) try { const response = await fetchWithTimeout( diff --git a/app/utils/user-info.server.ts b/app/utils/user-info.server.ts index f0118b14a..d9a36bda9 100644 --- a/app/utils/user-info.server.ts +++ b/app/utils/user-info.server.ts @@ -42,6 +42,13 @@ export async function gravatarExistsForEmail({ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 365, checkValue: (prevValue) => typeof prevValue === 'boolean', getFreshValue: async (context) => { + // In mocks mode we should not make external HTTP requests (can hang/flake + // CI/e2e runs, especially with Node v24 fetch abort issues). + if (process.env.MOCKS === 'true') { + context.metadata.ttl = 1000 * 60 * 60 * 24 * 365 + return false + } + const gravatarUrl = getAvatar(email, { fallback: '404' }) try { const timeoutMs = context.background || forceFresh ? 1000 * 10 : 100 From 7dd5ba5fc53f24813ccf52d188c0d69a239304c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 23:41:24 +0000 Subject: [PATCH 8/9] Use MSW for external password checks Co-authored-by: Kent C. Dodds --- app/utils/password.server.ts | 4 ---- app/utils/user-info.server.ts | 7 ------- mocks/index.ts | 4 ++-- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/app/utils/password.server.ts b/app/utils/password.server.ts index 8b1946439..ed7b17a84 100644 --- a/app/utils/password.server.ts +++ b/app/utils/password.server.ts @@ -75,10 +75,6 @@ function getPasswordHashParts(password: string) { } async function checkIsCommonPassword(password: string) { - // In mocks mode we don't want to make external HTTP requests for password - // strength checks (it can hang CI / e2e test runs and adds nondeterminism). - if (process.env.MOCKS === 'true') return false - const [prefix, suffix] = getPasswordHashParts(password) try { const response = await fetchWithTimeout( diff --git a/app/utils/user-info.server.ts b/app/utils/user-info.server.ts index d9a36bda9..f0118b14a 100644 --- a/app/utils/user-info.server.ts +++ b/app/utils/user-info.server.ts @@ -42,13 +42,6 @@ export async function gravatarExistsForEmail({ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 365, checkValue: (prevValue) => typeof prevValue === 'boolean', getFreshValue: async (context) => { - // In mocks mode we should not make external HTTP requests (can hang/flake - // CI/e2e runs, especially with Node v24 fetch abort issues). - if (process.env.MOCKS === 'true') { - context.metadata.ttl = 1000 * 60 * 60 * 24 * 365 - return false - } - const gravatarUrl = getAvatar(email, { fallback: '404' }) try { const timeoutMs = context.background || forceFresh ? 1000 * 10 : 100 diff --git a/mocks/index.ts b/mocks/index.ts index a303f2655..61e290127 100644 --- a/mocks/index.ts +++ b/mocks/index.ts @@ -54,8 +54,8 @@ const miscHandlers = [ }, ), http.head('https://www.gravatar.com/avatar/:md5Hash', async () => { - if (await isConnectedToTheInternet()) return passthrough() - + // In mocks mode we want deterministic behavior and to avoid external HTTP + // requests that can flake in CI (even when DNS works). return HttpResponse.json(null, { status: 404 }) }), http.get(/http:\/\/(localhost|127\.0\.0\.1):\d+\/.*/, async () => From 889a44f581c92f97db888fc15dcd738041b46624 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 23:45:35 +0000 Subject: [PATCH 9/9] MSW: passthrough gravatar in dev Co-authored-by: Kent C. Dodds --- mocks/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mocks/index.ts b/mocks/index.ts index 61e290127..7c17b0160 100644 --- a/mocks/index.ts +++ b/mocks/index.ts @@ -54,8 +54,13 @@ const miscHandlers = [ }, ), http.head('https://www.gravatar.com/avatar/:md5Hash', async () => { - // In mocks mode we want deterministic behavior and to avoid external HTTP - // requests that can flake in CI (even when DNS works). + // In development, allow real Gravatar lookups when possible. + // In tests/CI (and other non-dev modes), return 404 deterministically to + // avoid flaky external HTTP requests. + if (process.env.NODE_ENV !== 'development') { + return HttpResponse.json(null, { status: 404 }) + } + if (await isConnectedToTheInternet()) return passthrough() return HttpResponse.json(null, { status: 404 }) }), http.get(/http:\/\/(localhost|127\.0\.0\.1):\d+\/.*/, async () =>