Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)

Expand Down Expand Up @@ -176,4 +178,4 @@ export async function GET(req: Request) {
"casts": castsWithAuthorInfo,
"cursor": response.data.next.cursor
})
}
})
6 changes: 4 additions & 2 deletions src/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 })
}
})
7 changes: 5 additions & 2 deletions src/app/api/user/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -75,4 +78,4 @@ export async function GET(req: Request) {
}

return Response.json({ "state": supercastUserState })
}
})
8 changes: 8 additions & 0 deletions src/app/changelog/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 42 additions & 0 deletions src/utils/auth/AuthInfrastructureError.ts
Original file line number Diff line number Diff line change
@@ -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')
49 changes: 49 additions & 0 deletions src/utils/auth/authInfraResponse.ts
Original file line number Diff line number Diff line change
@@ -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 =
<Args extends unknown[]>(handler: (...args: Args) => Promise<Response>) =>
async (...args: Args): Promise<Response> => {
try {
return await handler(...args)
} catch (error) {
const infra = authInfraResponse(error)
if (infra) return infra
throw error
}
}
139 changes: 118 additions & 21 deletions src/utils/auth/isAuthenticated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,129 @@ 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<AuthenticationResponseData> => {

const privy = new PrivyClient(process.env.NEXT_PUBLIC_PRIVY_APP_ID, process.env.PRIVY_SECRET_KEY);

const authToken = getBearerToken(req);

if (!authToken) {
return {
authenticated: false,
supercastUser: null
}
return notAuthenticated;
}

// Stage 1 — verify the token. This is normally an offline cryptographic check,
// so a failure here usually means the token is missing/expired/tampered: a
// genuine auth failure. But verifyAuthToken may fetch the signing key over the
// network on a cold instance, so a *recognised* infra error (network/5xx/JWKS
// timeout) is surfaced as retryable instead. Default for anything ambiguous:
// treat as an auth failure, preserving the prior 401 behaviour for bad tokens.
let verifiedClaims: Awaited<ReturnType<typeof privy.verifyAuthToken>>;
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({
Expand All @@ -35,10 +135,9 @@ export const isAuthenticated = async (req: Request): Promise<AuthenticationRespo
});

if (!supercastPrivyUser) {
return {
authenticated: false,
supercastUser: null
}
// Token is valid but this Privy user hasn't connected a Castora account yet
// — a genuine "guest" state, not an error.
return notAuthenticated;
}

return {
Expand All @@ -48,16 +147,14 @@ export const isAuthenticated = async (req: Request): Promise<AuthenticationRespo

} catch (error) {

trackPosthogEvent(parseInt(req.headers.get('asFid') || '0'), 'auth_error', {
error_type: error.name,
error_message: JSON.stringify(error.message)
})

console.log(`Token verification failed with error ${error}.`);

return {
authenticated: false,
supercastUser: null
if (classifyAuthError(error) === 'auth') {
trackAuthEvent(req, 'auth_error', error);
console.log(`Privy rejected the user with error ${error}.`);
return notAuthenticated;
}

trackAuthEvent(req, 'auth_infra_error', error);
console.error('isAuthenticated: infrastructure failure resolving user (Privy API or database unreachable):', error);
throw infraError(error);
}
}
}
1 change: 1 addition & 0 deletions src/utils/posthogAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type POSTHOG_EVENT_TYPE =
| 'list_created'
| 'list_followed'
| 'auth_error'
| 'auth_infra_error'
| 'waitlist_signup'
| 'storage_purchased'
| 'registration_purchased'
Expand Down
Loading