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
89 changes: 48 additions & 41 deletions apps/bot/src/services/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,54 @@ import {

const { API_URL } = config;

const FETCH_TIMEOUT_MS = 20_000;

async function timedFetch(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

// Bot is a trusted service client: instead of rotating refresh tokens (which
// trips the API's reuse-detection when a response is lost to a cold start), it
// re-exchanges telegramId + name for a fresh pair on demand.
async function exchangeTokens(
telegramId: number,
name: string,
): Promise<TokenPair> {
const res = await timedFetch(`${API_URL}/auth/telegram/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "bot",
telegramId: String(telegramId),
name,
}),
});

if (!res.ok) {
throw new Error(`Telegram auth failed: ${res.status}`);
}

const data = (await res.json()) as TokenPair;
await setTokens(telegramId, data, name);
return data;
}

async function apiFetch(
method: string,
path: string,
telegramId: number,
body?: unknown,
retried = false,
): Promise<Response> {
const tokens = await getTokens(telegramId);

const res = await fetch(`${API_URL}${path}`, {
const res = await timedFetch(`${API_URL}${path}`, {
method,
headers: {
"Content-Type": "application/json",
Expand All @@ -26,56 +65,24 @@ async function apiFetch(
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});

if (res.status === 401 && tokens) {
const refreshed = await tryRefresh(telegramId, tokens.refreshToken);
if (refreshed) {
return apiFetch(method, path, telegramId, body);
if (res.status === 401 && tokens && !retried) {
try {
await exchangeTokens(telegramId, tokens.name);
} catch {
await deleteTokens(telegramId);
return res;
}
return apiFetch(method, path, telegramId, body, true);
}

return res;
}

async function tryRefresh(
telegramId: number,
refreshToken: string,
): Promise<boolean> {
const res = await fetch(`${API_URL}/auth/telegram/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});

if (!res.ok) {
await deleteTokens(telegramId);
return false;
}

const data = (await res.json()) as TokenPair;
await setTokens(telegramId, data);
return true;
}

export async function authenticateUser(
telegramId: number,
name: string,
): Promise<void> {
const res = await fetch(`${API_URL}/auth/telegram/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "bot",
telegramId: String(telegramId),
name,
}),
});

if (!res.ok) {
throw new Error(`Telegram auth failed: ${res.status}`);
}

const data = (await res.json()) as TokenPair;
await setTokens(telegramId, data);
await exchangeTokens(telegramId, name);
}

export async function isAuthenticated(telegramId: number): Promise<boolean> {
Expand Down
13 changes: 9 additions & 4 deletions apps/bot/src/services/tokenStore.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import { redis } from "../lib/redis.js";

export type TokenPair = { accessToken: string; refreshToken: string };
export type StoredSession = TokenPair & { name: string };

const key = (telegramId: number) => `bot:session:${telegramId}`;

// 30 days TTL – token pair is refreshed on each use
// 30 days TTL – tokens are re-exchanged on expiry, name lets us mint a fresh pair
const TTL_SECONDS = 60 * 60 * 24 * 30;

export async function getTokens(telegramId: number): Promise<TokenPair | null> {
export async function getTokens(
telegramId: number,
): Promise<StoredSession | null> {
const raw = await redis.get(key(telegramId));
if (!raw) return null;
return JSON.parse(raw) as TokenPair;
return JSON.parse(raw) as StoredSession;
}

export async function setTokens(
telegramId: number,
tokens: TokenPair,
name: string,
): Promise<void> {
await redis.set(key(telegramId), JSON.stringify(tokens), "EX", TTL_SECONDS);
const session: StoredSession = { ...tokens, name };
await redis.set(key(telegramId), JSON.stringify(session), "EX", TTL_SECONDS);
}

export async function deleteTokens(telegramId: number): Promise<void> {
Expand Down
Loading