Skip to content
Open
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
17 changes: 13 additions & 4 deletions src/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ensureDir, pathExists, readJson, readJsonLines, writeJson, writeJsonLin
import { ensureDataDir, twitterBackfillStatePath, twitterBookmarksCachePath, twitterBookmarksMetaPath } from './paths.js';
import type { BookmarkBackfillState, BookmarkCacheMeta, BookmarkRecord } from './types.js';
import { loadXApiConfig } from './config.js';
import { loadTwitterOAuthToken } from './xauth.js';
import { ensureValidTwitterToken, refreshTwitterTokenNow } from './xauth.js';

export interface BookmarkSyncResult {
mode: 'full' | 'incremental';
Expand Down Expand Up @@ -171,12 +171,21 @@ export async function syncTwitterBookmarks(
mode: 'full' | 'incremental',
options: { targetAdds?: number } = {}
): Promise<BookmarkSyncResult> {
const token = await loadTwitterOAuthToken();
const token = await ensureValidTwitterToken();
if (!token?.access_token) {
throw new Error('Missing user-context OAuth token. Run: ft auth');
}

const me = await fetchCurrentUserId(token.access_token);
let accessToken = token.access_token;
let me = await fetchCurrentUserId(accessToken);
if (!me.ok && me.status === 401) {
// Access token may have been revoked or expired ahead of its stated lifetime; refresh once and retry.
const refreshed = await refreshTwitterTokenNow();
if (refreshed?.access_token) {
accessToken = refreshed.access_token;
me = await fetchCurrentUserId(accessToken);
}
}
if (!me.ok || !me.id) {
throw new Error(`Could not resolve current user id: ${me.detail}`);
}
Expand All @@ -194,7 +203,7 @@ export async function syncTwitterBookmarks(
const maxPages = mode === 'full' ? 200 : 2;

while (pages < maxPages) {
const pageResult = await fetchBookmarksPage(token.access_token, me.id, nextToken);
const pageResult = await fetchBookmarksPage(accessToken, me.id, nextToken);
if (!pageResult.ok || !pageResult.page) {
throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`);
Comment on lines +206 to 208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh after 401s during bookmark pagination

When a full API sync starts with a token that is still outside the 5-minute refresh window but expires while walking pages, the initial /2/users/me retry path never runs again and a 401 from fetchBookmarksPage immediately aborts the sync. This can happen on large mode === 'full' runs (maxPages is 200) or slow/rate-limited requests, so unattended syncs can still fail mid-run even though a refresh token is available; refresh and retry the current page once on a 401 here as well.

Useful? React with 👍 / 👎.

}
Expand Down
68 changes: 68 additions & 0 deletions src/xauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,74 @@ async function exchangeCodeForToken(code: string, verifier: string): Promise<XOA
};
}

async function requestTokenRefresh(refreshToken: string): Promise<XOAuthTokenSet> {
const cfg = loadXApiConfig();
const basic = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: cfg.clientId,
});

const response = await fetch('https://api.x.com/2/oauth2/token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});

const text = await response.text();
if (!response.ok) {
throw new Error(`Token refresh failed (HTTP ${response.status}). Run: ft auth`);
}
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`Token refresh returned a non-JSON response (HTTP ${response.status}). Run: ft auth`);
}

return {
access_token: parsed.access_token,
// X rotates the refresh token on each refresh; keep the prior one only if the response omits it.
refresh_token: parsed.refresh_token ?? refreshToken,
expires_in: parsed.expires_in,
scope: parsed.scope,
token_type: parsed.token_type,
obtained_at: new Date().toISOString(),
};
}

function isAccessTokenExpired(token: XOAuthTokenSet, bufferSeconds = 300): boolean {
if (!token.expires_in) return false;
const obtainedMs = Date.parse(token.obtained_at);
if (Number.isNaN(obtainedMs)) return true;
const expiresAtMs = obtainedMs + token.expires_in * 1000;
return Date.now() >= expiresAtMs - bufferSeconds * 1000;
}

export async function ensureValidTwitterToken(): Promise<XOAuthTokenSet | null> {
const token = await loadTwitterOAuthToken();
if (!token?.access_token) return token;
if (!isAccessTokenExpired(token)) return token;
if (!token.refresh_token) return token;
const refreshed = await requestTokenRefresh(token.refresh_token);
// Persist the rotated token set before using it; writeJson is atomic (tmp + rename),
// so an interrupted write cannot strand us with the now-invalidated old refresh token.
await saveTwitterOAuthToken(refreshed);
return refreshed;
}

export async function refreshTwitterTokenNow(): Promise<XOAuthTokenSet | null> {
const token = await loadTwitterOAuthToken();
if (!token?.refresh_token) return null;
const refreshed = await requestTokenRefresh(token.refresh_token);
await saveTwitterOAuthToken(refreshed);
return refreshed;
}

export async function saveTwitterOAuthToken(token: XOAuthTokenSet): Promise<string> {
ensureDataDir();
const tokenPath = twitterOauthTokenPath();
Expand Down