Skip to content

Latest commit

 

History

History
584 lines (484 loc) · 17.2 KB

File metadata and controls

584 lines (484 loc) · 17.2 KB

🔍 Детальний Аналіз Кожної Проблеми з Прикладами

ПРОБЛЕМА #1: HttpError Not Caught

📍 Местоположение: server/_core/sdk.ts line 235-280

❌ БУЛО:

// 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);

☝️ Проблема 1: Де це викидається?

// 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

ПРОБЛЕМА #2: pushTokens Boolean Type Mismatch

📍 Местоположение: server/db.ts line 269-276, 288-292

❌ БУЛО:

// 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!
}

🧪 Как esto testear:

# Регіструємо 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"
# ✅ МА БУТИ: [] (тепер пусто)

ПРОБЛЕМА #3: OAuth Generic Error Handling

📍 Местоположение: server/_core/oauth.ts line 63-80

❌ БУЛО:

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.