fix(apps): device-flow diagnostics + bounded identity retries

Verified the wire calls are correct (both endpoints behave with this client
id from a plain node run), so instrument the runtime path instead of
guessing:

- log every poll outcome ([GitHubAuth] lines) at the token exchange, the
  identity fetch, and the IPC handler
- identity failures now retry at most 3 polls then fail loudly with the HTTP
  status — never silently degrade to 'pending' forever
- explicit User-Agent on all GitHub calls (defensive)
This commit is contained in:
Gagan 2026-07-06 16:12:53 +05:30
parent 0ca3606be5
commit 684ec0b390
2 changed files with 22 additions and 6 deletions

View file

@ -1697,7 +1697,9 @@ export function setupIpcHandlers() {
return result;
},
'githubAuth:poll': async () => {
return githubAuth.pollDeviceFlow();
const result = await githubAuth.pollDeviceFlow();
console.log(`[GitHubAuth] poll result → ${result.status}`);
return result;
},
'githubAuth:status': async () => {
return githubAuth.getAuthStatus();

View file

@ -37,7 +37,10 @@ type PendingFlow = {
expiresAt: number;
/** Token already issued but the identity fetch failed — retry that step. */
issuedToken?: string;
identityAttempts?: number;
};
const GH_HEADERS = { 'Accept': 'application/json', 'User-Agent': 'rowboat-apps' };
let pending: PendingFlow | null = null;
async function readAuth(): Promise<StoredAuth | null> {
@ -58,7 +61,7 @@ export async function startDeviceFlow(): Promise<{ userCode: string; verificatio
// GitHub's OAuth endpoints take form-encoded params (JSON is not reliable).
const res = await fetch('https://github.com/login/device/code', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }).toString(),
});
if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`);
@ -92,7 +95,7 @@ export async function pollDeviceFlow(): Promise<PollResult> {
if (!accessToken) {
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT_ID,
device_code: pending.deviceCode,
@ -100,6 +103,7 @@ export async function pollDeviceFlow(): Promise<PollResult> {
}).toString(),
});
const body = await res.json() as { access_token?: string; error?: string; error_description?: string };
console.log(`[GitHubAuth] poll: http=${res.status} error=${body.error ?? 'none'} token=${body.access_token ? 'ISSUED' : 'no'}`);
if (body.error === 'authorization_pending') return { status: 'pending' };
if (body.error === 'slow_down') {
@ -127,11 +131,21 @@ export async function pollDeviceFlow(): Promise<PollResult> {
pending.issuedToken = accessToken;
}
// Identity: cache login alongside the token.
// Identity: cache login alongside the token. Bounded retries — a
// persistent failure must surface, never spin as "pending" forever.
const userRes = await fetch('https://api.github.com/user', {
headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/vnd.github+json' },
headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/vnd.github+json', 'User-Agent': 'rowboat-apps' },
});
if (!userRes.ok) return { status: 'pending' }; // retry identity next poll
console.log(`[GitHubAuth] identity: http=${userRes.status}`);
if (!userRes.ok) {
pending.identityAttempts = (pending.identityAttempts ?? 0) + 1;
if (pending.identityAttempts >= 3) {
const detail = await userRes.text().catch(() => '');
pending = null;
throw new Error(`identity_failed: GET /user → HTTP ${userRes.status} ${detail.slice(0, 160)}`);
}
return { status: 'pending' };
}
const user = await userRes.json() as { login: string };
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };