// authenticateRequest кидає HttpError
async authenticateRequest(req: Request): Promise<User> {
const authHeader = req.headers.authorization || req.headers.Authorization;
let token: string | undefined;
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length).trim();
}
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = token || cookies.get(COOKIE_NAME);
const session = await this.verifySession(sessionCookie);
if (!session) {
throw ForbiddenError("Invalid session cookie"); // 🔴 ВИКИДАЄ
}
// ... решта коду ...
if (!user) {
throw ForbiddenError("User not found"); // 🔴 ВИКИДАЄ
}
return user;
}
// ForbiddenError це класс, що розширює HttpError:
export const ForbiddenError = (msg: string) => new HttpError(403, msg);// server/_core/index.ts
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext, // 🔴 Ось де повинна бути обробка!
}),
);
// createContext вызывает sdk.authenticateRequest()
export async function createContext(opts: CreateExpressContextOptions): Promise<TrpcContext> {
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req); // 🔴 МОЖЕ ВИКИНУТИ!
} catch (error) {
// Але тут тільки логується, не перетворюється в TRPCError!
user = null; // 🔴 НЕХОРОШЕ!
}
return { req: opts.req, res: opts.res, user };
}User requests /api/trpc/auth.me (unauthenticated)
↓
createContext() called
↓
sdk.authenticateRequest() called
↓
verifySession returns null
↓
throw ForbiddenError("Invalid session cookie") // HttpError(403)
↓
catch (error) { user = null } // ✅ СПІЙМАНО
↓
OK, user = null, але ЗАХИЩЕНІ маршрути будуть дозволені!
↓
❌ ПОМИЛКА: Захищений маршрут може запуститися з user=null
// shared/_core/errors.ts - ДОБАВИТИ:
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
// server/_core/context.ts
export async function createContext(opts: CreateExpressContextOptions): Promise<TrpcContext> {
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
} catch (error) {
// ✅ ДОБАВИТИ: Log детально
if (error instanceof HttpError) {
console.log(`[Auth] HTTP Error ${error.statusCode}: ${error.message}`);
// Це НОРМАЛЬНО для публічних маршрутів
} else {
console.error("[Auth] Unexpected error:", error);
}
user = null;
}
return { req: opts.req, res: opts.res, user };
}
// server/_core/index.ts
// ✅ ДОБАВИТИ: Error middleware ДО всех роутерів
function errorHandler(
err: Error,
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
// Обробляємо HttpError
if (err instanceof HttpError) {
console.error(`[Error] ${err.statusCode}: ${err.message}`);
res.status(err.statusCode).json({
error: err.message,
code: err.statusCode === 403 ? "FORBIDDEN" : "HTTP_ERROR"
});
return;
}
// Обробляємо TRPCError (це вже обробляється tRPC)
if (err.name === "TRPCError") {
// tRPC вже це обробляє, просто логуємо
console.error(`[tRPC Error] ${err.message}`);
return;
}
// Інші помилки
console.error('[Unexpected Error]', err);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
async function startServer() {
const app = express();
// ... CORS, JSON parser ...
registerOAuthRoutes(app);
app.get("/api/health", ...);
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
}),
);
// ✅ ДОБАВИТИ: Error middleware в КІНЦІ
app.use(errorHandler);
// ... rest of code ...
}# Без auth
curl -X POST http://localhost:3000/api/trpc/favorites.list
# Мало бути: 403 FORBIDDEN error (не 500)
# З неправильним токеном
curl -X POST http://localhost:3000/api/trpc/favorites.list \
-H "Authorization: Bearer invalid"
# Мало бути: 403 FORBIDDEN error
# Публічний маршрут без auth
curl -X POST http://localhost:3000/api/trpc/bfl.getAllLeagues
# Мало бути: 200 OK// drizzle/schema.ts (передбачається, що поле це boolean)
pushTokens: mysqlTable('pushTokens', {
id: bigint('id').primaryKey().autoincrement(),
userId: bigint('userId').notNull().references(() => users.id),
token: varchar('token', { length: 255 }).notNull().unique(),
platform: varchar('platform', { length: 50 }).notNull(),
enabled: boolean('enabled').notNull(), // ✅ BOOLEAN
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().onUpdateNow().notNull(),
});
// server/db.ts
export async function registerPushToken(
userId: number,
token: string,
platform: "ios" | "android" | "web"
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { pushTokens } = await import("../drizzle/schema");
await db
.insert(pushTokens)
.values({
userId,
token,
platform,
enabled: "true", // 🔴 STRING не BOOLEAN!
})
.onDuplicateKeyUpdate({
set: { userId, platform, updatedAt: new Date() },
});
}
export async function disablePushToken(token: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { pushTokens } = await import("../drizzle/schema");
// 🔴 ОШИБКА: Вставляємо string "false"
await db
.update(pushTokens)
.set({ enabled: "false", updatedAt: new Date() }) // STRING!
.where(eq(pushTokens.token, token));
}
export async function getUserPushTokens(userId: number) {
const db = await getDb();
if (!db) return [];
const { pushTokens } = await import("../drizzle/schema");
return db
.select()
.from(pushTokens)
.where(and(
eq(pushTokens.userId, userId),
eq(pushTokens.enabled, "true") // 🔴 ПОМИЛКА: Шукаємо STRING "true"
)); // Але в БД у нас BOOLEAN true!
}
export async function getAllActivePushTokens() {
const db = await getDb();
if (!db) return [];
const { pushTokens } = await import("../drizzle/schema");
return db
.select()
.from(pushTokens)
.where(eq(pushTokens.enabled, "true")); // 🔴 ПОМИЛКА!
}-- Регіструємо токен:
INSERT INTO pushTokens (userId, token, platform, enabled)
VALUES (1, 'abc123', 'ios', 'true'); -- 🔴 STRING "true" в BOOLEAN поле!
-- Потім шукаємо токени:
SELECT * FROM pushTokens
WHERE enabled = 'true'; -- 🔴 ПОРІВНЮЄМО STRING "true" з ...?
-- 🔴 ПОМИЛКА: В MySQL "true" (string) != true (boolean)
-- Результат: EMPTY ARRAY! Push notifications don't work!
-- Правильно має бути:
SELECT * FROM pushTokens WHERE enabled = true; -- Це роботатиме!// server/db.ts
export async function registerPushToken(
userId: number,
token: string,
platform: "ios" | "android" | "web"
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { pushTokens } = await import("../drizzle/schema");
// ✅ КОРИСТУЄМО BOOLEAN не STRING
await db
.insert(pushTokens)
.values({
userId,
token,
platform,
enabled: true, // ✅ BOOLEAN true!
})
.onDuplicateKeyUpdate({
set: { userId, platform, updatedAt: new Date() },
});
}
export async function disablePushToken(token: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const { pushTokens } = await import("../drizzle/schema");
// ✅ КОРИСТУЄМО BOOLEAN false!
await db
.update(pushTokens)
.set({ enabled: false, updatedAt: new Date() }) // ✅ BOOLEAN!
.where(eq(pushTokens.token, token));
}
export async function getUserPushTokens(userId: number) {
const db = await getDb();
if (!db) return [];
const { pushTokens } = await import("../drizzle/schema");
// ✅ КОРИСТУЄМО BOOLEAN true!
return db
.select()
.from(pushTokens)
.where(and(
eq(pushTokens.userId, userId),
eq(pushTokens.enabled, true) // ✅ BOOLEAN!
));
}
export async function getAllActivePushTokens() {
const db = await getDb();
if (!db) return [];
const { pushTokens } = await import("../drizzle/schema");
// ✅ КОРИСТУЄМО BOOLEAN true!
return db
.select()
.from(pushTokens)
.where(eq(pushTokens.enabled, true)); // ✅ BOOLEAN!
}# Регіструємо push token
curl -X POST http://localhost:3000/api/trpc/push.register \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"token":"test123","platform":"ios"}'
# Response: { success: true }
# Отримуємо токени користувача
curl -X GET http://localhost:3000/api/trpc/push.getTokens \
-H "Authorization: Bearer TOKEN"
# ✅ МА БУТИ: [{ token: "test123", ... }]
# ❌ БУЛО: [] (пусто!)
# Вимикаємо токен
curl -X POST http://localhost:3000/api/trpc/push.disable \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"token":"test123"}'
# Response: { success: true }
# Проверяємо
curl -X GET http://localhost:3000/api/trpc/push.getTokens \
-H "Authorization: Bearer TOKEN"
# ✅ МА БУТИ: [] (тепер пусто)app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
await syncUser(userInfo);
const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
const frontendUrl = process.env.EXPO_WEB_PREVIEW_URL ||
process.env.EXPO_PACKAGER_PROXY_URL ||
"http://localhost:8081";
res.redirect(302, frontendUrl);
} catch (error) {
// 🔴 ПРОБЛЕМА: Всі помилки однакові!
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});Сценарій 1: OAuth server DOWN
└─ exchangeCodeForToken() fails with timeout
└─ Catch: 500 "OAuth callback failed"
└─ 😞 User не знає що сталось
Сценарій 2: Invalid OAuth code
└─ exchangeCodeForToken() fails with 401
└─ Catch: 500 "OAuth callback failed"
└─ 😞 Should be 400 not 500!
Сценарій 3: getUserInfo fails
└─ getUserInfo() fails with 403
└─ Catch: 500 "OAuth callback failed"
└─ 😞 Could be permission issue
Сценарій 4: Database down during syncUser
└─ syncUser() fails with DB error
└─ Catch: 500 "OAuth callback failed"
└─ 😞 Неможливо розрізнити від OAuth error
РЕЗУЛЬТАТ:
- Неможна дебагити проблему
- Всі помилки мають одинаковий код 500
- Фронтенд не знає що робити
- Логи не допомагають
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
console.warn("[OAuth] Missing code or state in callback");
res.status(400).json({
error: "Invalid OAuth callback - missing parameters",
code: "INVALID_PARAMS"
});
return;
}
// ✅ ОКРЕМНА обробка для кожного кроку
// Step 1: Exchange token
let tokenResponse;
try {
tokenResponse = await sdk.exchangeCodeForToken(code, state);
} catch (error) {
// ✅ Окремна обробка
console.error("[OAuth] Token exchange failed:", error);
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
res.status(400).json({
error: "Invalid OAuth code or state",
code: "INVALID_CODE"
});
return;
}
if (error.code === 'ECONNREFUSED') {
res.status(503).json({
error: "OAuth server is unavailable",
code: "SERVICE_UNAVAILABLE"
});
return;
}
}
res.status(400).json({
error: "Failed to exchange OAuth token",
code: "TOKEN_EXCHANGE_FAILED"
});
return;
}
// Step 2: Get user info
let userInfo;
try {
userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
} catch (error) {
// ✅ Окремна обробка
console.error("[OAuth] Failed to get user info:", error);
if (error instanceof AxiosError && error.response?.status === 403) {
res.status(403).json({
error: "Insufficient permissions from OAuth provider",
code: "PERMISSION_DENIED"
});
return;
}
res.status(401).json({
error: "Failed to retrieve user information",
code: "USER_INFO_FAILED"
});
return;
}
// Step 3: Sync user to DB
try {
await syncUser(userInfo);
} catch (error) {
// ✅ Окремна обробка
console.error("[OAuth] Failed to sync user:", error);
res.status(500).json({
error: "Failed to complete OAuth flow",
code: "USER_SYNC_FAILED"
});
return;
}
// Step 4: Create session
try {
const sessionToken = await sdk.createSessionToken(userInfo.openId!, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
const frontendUrl = process.env.EXPO_WEB_PREVIEW_URL ||
process.env.EXPO_PACKAGER_PROXY_URL ||
"http://localhost:8081";
res.redirect(302, frontendUrl);
} catch (error) {
// ✅ Окремна обробка
console.error("[OAuth] Failed to create session:", error);
res.status(500).json({
error: "Failed to create session",
code: "SESSION_CREATION_FAILED"
});
}
});| № | Проблема | Причина | Рішення | Час |
|---|---|---|---|---|
| 1 | HttpError не перехоплюється | Немає middleware | Додати error handler | 30 min |
| 2 | pushTokens STRING не BOOLEAN | Тип мах | Замінити 4 функції | 10 min |
| 3 | OAuth помилки однакові | Обробка у одному catch | Розділити try-catch | 45 min |
| 4 | API response не валідується | Немає перевірки | Додати if (!data) | 20 min |
| 5 | Немає URL validation | Забути додати | Додати new URL() | 15 min |
Всього часу: ~2 години на все!
Докладніші приклади дивіться у API_FIXES_GUIDE.md.