From 5ce922aa191ca891eda5b0a173a81f387152b83e Mon Sep 17 00:00:00 2001 From: arcabotai <261107832+arcabotai@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:16:21 -0400 Subject: [PATCH] fix(auth): don't treat infra outages as "not authenticated" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isAuthenticated() wrapped token verification AND the Privy/DB user lookup in one try/catch that returned { authenticated: false } on ANY error. A transient Railway Postgres or Privy blip therefore made every data route report "not authenticated" — emptying the timeline and, via /api/user/state's 200 guest payload, silently demoting signed-in users to guest / connect-account onboarding (dangerous on shared dev==prod data). Split the check into two stages with a shared error classifier: - token verification failures (missing/expired/invalid token) keep returning { authenticated: false } -> real 401, unchanged - Privy API / database failures throw AuthInfrastructureError so routes can return a retryable 503 instead of a false 401 Timeline-critical routes (user/state, feed, notifications) return 503 on infra failure via a reusable withAuthInfra() wrapper; the remaining ~110 callers surface a transient outage as an honest 500 (never a false 401). Adds an auth_infra_error analytics event to separate outages from token failures. Also hardens .gitignore against credential-file commits. Verified: typecheck + build green. --- .gitignore | 5 + CHANGELOG.md | 3 + src/app/api/feed/route.ts | 6 +- src/app/api/notifications/route.ts | 6 +- src/app/api/user/state/route.ts | 7 +- src/app/changelog/entries.ts | 8 ++ src/utils/auth/AuthInfrastructureError.ts | 42 +++++++ src/utils/auth/authInfraResponse.ts | 49 ++++++++ src/utils/auth/isAuthenticated.ts | 139 ++++++++++++++++++---- src/utils/posthogAnalytics.ts | 1 + 10 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 src/utils/auth/AuthInfrastructureError.ts create mode 100644 src/utils/auth/authInfraResponse.ts diff --git a/.gitignore b/.gitignore index a0fba4e..f339b52 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ next-env.d.ts .env.* !.env.example +# Credential dumps — keep these OUTSIDE the repo; never commit +*[Cc]redential* +*.secrets +*.secrets.* + # Build/cache .next .vercel diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e7b5a..46043b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ the public changelog at [castora.social/changelog](https://castora.social/change > `src/app/changelog/entries.ts` (the website source of truth, newest first) and > mirror it here. Keep wording user-friendly. See `AGENTS.md`. +## 2026-06-22 — Sign-in reliability +- Fixed a rare issue where a brief backend hiccup could empty your timeline or bounce you to the connect-account screen even while you were still signed in. The app now treats a momentary outage as something to retry, instead of mistaking it for a sign-out. + ## 2026-05-29 — Reliability & security hardening - Centralized all Farcaster data access behind a single, server-only client for safer and more consistent loading. - Added continuous integration — every change is now type-checked, built, and scanned for leaked secrets before it ships. diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts index 7ef8799..63f96d4 100644 --- a/src/app/api/feed/route.ts +++ b/src/app/api/feed/route.ts @@ -3,6 +3,7 @@ import { neynar } from '@/lib/neynar' import { prisma } from '@/prisma/client' import { isAuthenticated } from "@/utils/auth/isAuthenticated"; import { isAuthorized } from "@/utils/auth/isAuthorized"; +import { withAuthInfra } from "@/utils/auth/authInfraResponse"; import { trackPosthogEvent } from "@/utils/posthogAnalytics"; import { redis } from '@/utils/redis' @@ -11,7 +12,8 @@ const CACHE_TTL = 60 * 60 // 1 hour in seconds // Initialize Redis client -export async function GET(req: Request) { +// Surface a transient DB/Privy auth outage as a retryable 503, not a false 401. +export const GET = withAuthInfra(async (req: Request) => { const { authenticated, supercastUser } = await isAuthenticated(req) @@ -176,4 +178,4 @@ export async function GET(req: Request) { "casts": castsWithAuthorInfo, "cursor": response.data.next.cursor }) -} +}) diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index ab570db..de0e09c 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -4,9 +4,11 @@ import { neynar } from '@/lib/neynar' import { prisma } from '@/prisma/client' import { isAuthenticated } from "@/utils/auth/isAuthenticated" import { isAuthorized } from "@/utils/auth/isAuthorized" +import { withAuthInfra } from "@/utils/auth/authInfraResponse" import { trackPosthogEvent } from "@/utils/posthogAnalytics" -export async function GET(req: Request) { +// Surface a transient DB/Privy auth outage as a retryable 503, not a false 401. +export const GET = withAuthInfra(async (req: Request) => { const { authenticated, supercastUser } = await isAuthenticated(req) @@ -37,4 +39,4 @@ export async function GET(req: Request) { } return Response.json({ "unread": response.data.unseen_notifications_count, "notifications": response.data.notifications, "cursor": response.data.next.cursor }) -} \ No newline at end of file +}) \ No newline at end of file diff --git a/src/app/api/user/state/route.ts b/src/app/api/user/state/route.ts index 54fd609..8f3ee44 100644 --- a/src/app/api/user/state/route.ts +++ b/src/app/api/user/state/route.ts @@ -2,11 +2,14 @@ import { prisma } from "@/prisma/client"; import { SupercastUserState } from "@/types"; import { isAuthenticated } from "@/utils/auth/isAuthenticated"; import { isAuthorized } from "@/utils/auth/isAuthorized"; +import { withAuthInfra } from "@/utils/auth/authInfraResponse"; import { PLAN } from "@prisma/client"; import axios from "axios"; import { neynar } from '@/lib/neynar' -export async function GET(req: Request) { +// A transient DB/Privy outage must degrade to a retryable 503, never a guest +// payload — otherwise a real, signed-in user is silently demoted to guest. +export const GET = withAuthInfra(async (req: Request) => { const { authenticated, supercastUser } = await isAuthenticated(req) @@ -75,4 +78,4 @@ export async function GET(req: Request) { } return Response.json({ "state": supercastUserState }) -} \ No newline at end of file +}) \ No newline at end of file diff --git a/src/app/changelog/entries.ts b/src/app/changelog/entries.ts index 0aa0958..76170d6 100644 --- a/src/app/changelog/entries.ts +++ b/src/app/changelog/entries.ts @@ -18,6 +18,14 @@ export type ChangelogEntry = { } export const changelog: ChangelogEntry[] = [ + { + date: '2026-06-22', + title: 'Sign-in reliability', + tag: 'fix', + items: [ + 'Fixed a rare issue where a brief backend hiccup could empty your timeline or bounce you to the connect-account screen even while you were still signed in. The app now treats a momentary outage as something to retry, instead of mistaking it for a sign-out.', + ], + }, { date: '2026-05-29', title: 'Reliability & security hardening', diff --git a/src/utils/auth/AuthInfrastructureError.ts b/src/utils/auth/AuthInfrastructureError.ts new file mode 100644 index 0000000..c69cbea --- /dev/null +++ b/src/utils/auth/AuthInfrastructureError.ts @@ -0,0 +1,42 @@ +/** + * Thrown by `isAuthenticated` when authentication cannot be determined because an + * underlying dependency is unreachable — the Privy API or the database — as + * opposed to a genuine "this token is invalid" auth failure. + * + * Why this exists: the auth check used to wrap token verification AND the Privy/DB + * lookups in a single try/catch that returned `{ authenticated: false }` on ANY + * error. So a transient Railway Postgres blip made every data route report "not + * authenticated" → empty timeline → real users dropped into guest / connect-account + * onboarding. Because dev and prod share one database, that onboarding could even + * write duplicate records into production. + * + * The fix: a transient infrastructure failure must NEVER be reported to the client + * as "not authenticated" (401). Routes should surface this as a retryable 503 so the + * client retries instead of signing the user out. See `authInfraResponse`. + */ +export class AuthInfrastructureError extends Error { + /** HTTP status routes should use when surfacing this error. */ + readonly status = 503 as const + + constructor(message: string, options?: { cause?: unknown }) { + super(message) + this.name = 'AuthInfrastructureError' + // Preserve the original error for logging without depending on the ES2022 + // `Error.cause` constructor option (kept compatible with older TS targets). + if (options?.cause !== undefined) { + ;(this as { cause?: unknown }).cause = options.cause + } + } +} + +/** + * Type guard for `AuthInfrastructureError`. Also duck-types by `name` so it still + * works if the class is duplicated across module/bundle boundaries. + */ +export const isAuthInfrastructureError = ( + error: unknown, +): error is AuthInfrastructureError => + error instanceof AuthInfrastructureError || + (typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'AuthInfrastructureError') diff --git a/src/utils/auth/authInfraResponse.ts b/src/utils/auth/authInfraResponse.ts new file mode 100644 index 0000000..16f3b10 --- /dev/null +++ b/src/utils/auth/authInfraResponse.ts @@ -0,0 +1,49 @@ +import { isAuthInfrastructureError } from './AuthInfrastructureError' + +/** + * If `error` is an auth infrastructure failure (DB/Privy unreachable), returns a + * retryable 503 Response; otherwise returns `null` so the caller can rethrow or + * handle it itself. + * + * Use this in route handlers that the client relies on to determine sign-in state + * (e.g. `user/state`) so a transient outage degrades to "retry" instead of a 401 + * that would sign a real user out and drop them into guest onboarding: + * + * try { + * const { authenticated, supercastUser } = await isAuthenticated(req) + * // ... + * } catch (error) { + * return authInfraResponse(error) ?? Promise.reject(error) + * } + * + * Routes that don't opt in will simply surface the thrown error as a generic 500 — + * still honest and retryable, and crucially never a false 401. + */ +export const authInfraResponse = (error: unknown): Response | null => { + if (!isAuthInfrastructureError(error)) return null + + return Response.json( + { error: 'Service temporarily unavailable. Please retry.' }, + { status: 503, headers: { 'Retry-After': '5' } }, + ) +} + +/** + * Wraps a route handler so an `AuthInfrastructureError` (DB/Privy unreachable) + * surfaces as a retryable 503 instead of a generic 500. Any other error is + * rethrown unchanged (Next.js renders it as 500). Apply to routes the client + * relies on for sign-in/timeline state so a transient outage degrades to "retry": + * + * export const GET = withAuthInfra(async (req: Request) => { ... }) + */ +export const withAuthInfra = + (handler: (...args: Args) => Promise) => + async (...args: Args): Promise => { + try { + return await handler(...args) + } catch (error) { + const infra = authInfraResponse(error) + if (infra) return infra + throw error + } + } diff --git a/src/utils/auth/isAuthenticated.ts b/src/utils/auth/isAuthenticated.ts index d6a464c..19bd448 100644 --- a/src/utils/auth/isAuthenticated.ts +++ b/src/utils/auth/isAuthenticated.ts @@ -3,12 +3,89 @@ import { SupercastPrivyUser } from '@prisma/client'; import { PrivyClient } from '@privy-io/server-auth'; import { trackPosthogEvent } from '../posthogAnalytics'; import { getBearerToken } from './getBearerToken'; +import { AuthInfrastructureError } from './AuthInfrastructureError'; type AuthenticationResponseData = { authenticated: boolean, supercastUser: SupercastPrivyUser | null } +// HTTP statuses (from Privy or any wrapped HTTP error) that mean the request/token +// was *rejected* — a genuine auth failure — rather than the service being down. +const CLIENT_AUTH_STATUSES = new Set([400, 401, 403, 404]); + +// jose error codes that mean the JWT itself is bad → a genuine auth failure. +// (Privy's verifyAuthToken validates the token with jose under the hood.) +const JWT_AUTH_ERROR_CODES = new Set([ + 'ERR_JWT_EXPIRED', + 'ERR_JWT_INVALID', + 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JWS_INVALID', + 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWE_INVALID', + 'ERR_JOSE_NOT_SUPPORTED', +]); + +// Node/undici/jose error codes that signal a dependency is unreachable → infra. +// (Note: verifyAuthToken may fetch the signing key over the network on a cold +// instance, so a network failure there is infra, not a bad token.) +const NETWORK_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EPIPE', + 'ECONNABORTED', + 'ERR_JWKS_TIMEOUT', + 'ERR_JWKS_NO_MATCHING_KEY', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_SOCKET', +]); + +type AuthErrorKind = 'auth' | 'infra' | 'unknown'; + +/** + * Classify an error thrown while authenticating: + * - 'auth' → the token/request was genuinely rejected (caller returns 401) + * - 'infra' → a dependency (Privy API or the database) is unreachable; this is a + * transient outage and must NOT be reported as "not authenticated" + * - 'unknown' → can't tell; the caller applies a per-stage default + */ +const classifyAuthError = (error: any): AuthErrorKind => { + const status = + typeof error?.status === 'number' ? error.status : + typeof error?.response?.status === 'number' ? error.response.status : + undefined; + if (status !== undefined) return CLIENT_AUTH_STATUSES.has(status) ? 'auth' : 'infra'; + + const type = error?.type; + if (type === 'client_error') return 'auth'; + if (type === 'api_error') return 'infra'; + + const code = typeof error?.code === 'string' ? error.code : ''; + if (JWT_AUTH_ERROR_CODES.has(code)) return 'auth'; + if (NETWORK_ERROR_CODES.has(code)) return 'infra'; + + return 'unknown'; +}; + +const trackAuthEvent = (req: Request, event: 'auth_error' | 'auth_infra_error', error: any) => { + trackPosthogEvent(parseInt(req.headers.get('asFid') || '0'), event, { + error_type: error?.name, + error_message: JSON.stringify(error?.message), + }); +}; + +const notAuthenticated: AuthenticationResponseData = { authenticated: false, supercastUser: null }; + +const infraError = (error: unknown) => + new AuthInfrastructureError( + 'Authentication is temporarily unavailable (Privy API or database unreachable).', + { cause: error }, + ); + export const isAuthenticated = async (req: Request): Promise => { const privy = new PrivyClient(process.env.NEXT_PUBLIC_PRIVY_APP_ID, process.env.PRIVY_SECRET_KEY); @@ -16,16 +93,39 @@ export const isAuthenticated = async (req: Request): Promise>; try { - const verifiedClaims = await privy.verifyAuthToken(authToken); + verifiedClaims = await privy.verifyAuthToken(authToken); + } catch (error) { + if (classifyAuthError(error) === 'infra') { + trackAuthEvent(req, 'auth_infra_error', error); + console.error('isAuthenticated: infrastructure failure verifying token (Privy unreachable):', error); + throw infraError(error); + } + trackAuthEvent(req, 'auth_error', error); + console.log(`Token verification failed with error ${error}.`); + return notAuthenticated; + } - // lookup privy data about this user + // Stage 2 — resolve the user via the Privy API (network) and our database. A + // throw here is almost always an INFRASTRUCTURE failure (Privy outage or the + // database being unreachable), NOT an auth failure. We must NOT collapse it to + // "not authenticated": a transient blip would then empty the timeline and drop a + // real, signed-in user into guest / connect-account onboarding (which, on shared + // dev==prod data, can write duplicate records). Surface it as an + // AuthInfrastructureError so routes can return a retryable 503. Default for + // anything ambiguous: treat as infra; only a *recognised* client/auth rejection + // (Privy 4xx) is reported as not-authenticated. + try { const user = await privy.getUser(verifiedClaims.userId); const supercastPrivyUser = await prisma.supercastPrivyUser.findUnique({ @@ -35,10 +135,9 @@ export const isAuthenticated = async (req: Request): Promise