diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx
index aede5250..c16c2d32 100644
--- a/src/app/(frontend)/layout.tsx
+++ b/src/app/(frontend)/layout.tsx
@@ -77,8 +77,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
>
-
-
+ {process.env.VERCEL === '1' ? (
+ <>
+
+
+ >
+ ) : null}
{children}
diff --git a/src/utilities/webauthn/campaignWebAuthnConfig.ts b/src/utilities/webauthn/campaignWebAuthnConfig.ts
index a5879f66..d1008573 100644
--- a/src/utilities/webauthn/campaignWebAuthnConfig.ts
+++ b/src/utilities/webauthn/campaignWebAuthnConfig.ts
@@ -17,6 +17,12 @@ export type CampaignWebAuthnRelyingParty = {
const RP_NAME = 'Campanha Jorge Solla'
+/** Matches `LOCAL_AUTHORITY_PATTERN` in `campaignInviteOrigin` — kept local on purpose. */
+const LOCAL_REQUEST_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/i
+
+const isLocalRequestHost = (host: string | undefined): boolean =>
+ Boolean(host && LOCAL_REQUEST_HOST.test(host))
+
/**
* The authority the browser is actually talking to, which is what has to agree
* with the relying party. Compared as `host` (name + port) and not as a full
@@ -61,9 +67,17 @@ const requestHostOf = ({
export const getCampaignWebAuthnRelyingParty = (
input: CampaignInviteOriginInput = {},
): CampaignWebAuthnRelyingParty | null => {
+ const requestHost = requestHostOf(input)
+ // `getCampaignInviteBaseURL` reads `process.env.NODE_ENV`, which Next inlines
+ // at build time as `"production"`. CI e2e serves that build on
+ // http://localhost:3000 (`pnpm start`), which would otherwise demand HTTPS
+ // DNS and hide biometrics. The request host is authoritative here.
+ const environment =
+ input.environment ?? (isLocalRequestHost(requestHost) ? ('test' as const) : undefined)
+
let baseURL: string
try {
- baseURL = getCampaignInviteBaseURL(input)
+ baseURL = getCampaignInviteBaseURL(environment ? { ...input, environment } : input)
} catch {
return null
}
@@ -74,7 +88,6 @@ export const getCampaignWebAuthnRelyingParty = (
// Absent host: a non-request context (a script, a test) that cannot be
// checked. The ceremonies always pass the headers.
- const requestHost = requestHostOf(input)
if (requestHost && requestHost !== url.host.toLowerCase()) return null
return { rpID, rpName: RP_NAME, origin: url.origin }
diff --git a/tests/e2e/campaignSavedFilters.e2e.spec.ts b/tests/e2e/campaignSavedFilters.e2e.spec.ts
index 5c34fd90..dfb42227 100644
--- a/tests/e2e/campaignSavedFilters.e2e.spec.ts
+++ b/tests/e2e/campaignSavedFilters.e2e.spec.ts
@@ -55,11 +55,14 @@ test.describe('Filtros salvos de Municípios', () => {
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// A page is a position inside the recorte, not part of it.
- await page.goto(`${SAVED_RECORTE}&page=2`)
+ await page.goto(`${SAVED_RECORTE}&page=2`, NAVIGATION)
await expect(shortcut).toHaveAttribute('aria-current', 'page')
- // Another recorte is not this one, and the shortcut brings it back.
- await page.goto('/campanha/municipios')
+ // Another recorte is not this one, and the shortcut brings it back. Use the
+ // sidebar link — a bare `goto` here races an in-flight RSC navigation and
+ // flakes as net::ERR_ABORTED under prod load (measured in CI 2026-07-30).
+ await page.getByRole('link', { name: 'Municípios', exact: true }).click()
+ await expect(page).toHaveURL('/campanha/municipios', NAVIGATION)
await expect(shortcut).not.toHaveAttribute('aria-current', 'page')
await shortcut.click()
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)
diff --git a/tests/unit/campaignWebAuthnConfig.unit.spec.ts b/tests/unit/campaignWebAuthnConfig.unit.spec.ts
index 0cb57115..d6b53b97 100644
--- a/tests/unit/campaignWebAuthnConfig.unit.spec.ts
+++ b/tests/unit/campaignWebAuthnConfig.unit.spec.ts
@@ -46,6 +46,15 @@ describe('getCampaignWebAuthnRelyingParty', () => {
).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3100' })
})
+ it('follows localhost when a production build is served there (CI e2e)', () => {
+ expect(
+ getCampaignWebAuthnRelyingParty({
+ configuredURL: 'https://pt.jorgesolla.com.br',
+ forwardedHost: 'localhost:3000',
+ }),
+ ).toMatchObject({ rpID: 'localhost', origin: 'http://localhost:3000' })
+ })
+
it('refuses production without a configured site URL', () => {
expect(
getCampaignWebAuthnRelyingParty({ environment: 'production', configuredURL: undefined }),