mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
92

Configure Feed

Select the types of activity you want to include in your feed.

fix: drop sync jobs cleanly when the atproto session is gone

Daniel Roe (Jul 10, 2026, 1:20 PM +0200) 735feac2 d93ff76b

+77 -5
+33
server/utils/atproto-oauth.ts
··· 153 153 } 154 154 } 155 155 156 + /** 157 + * True when restoring an OAuth session failed because the session no longer 158 + * exists or can't be refreshed: the user revoked the app on their PDS, the 159 + * refresh token expired, or the session row was never written / already 160 + * deleted. The library signals all of these by throwing `TokenRefreshError` / 161 + * `TokenRevokedError`; we also match the message for resilience across library 162 + * versions. Callers treat this as a benign drop rather than a retryable error, 163 + * so per-DID work doesn't loop forever once a user disconnects. 164 + */ 165 + export function isSessionGone(err: unknown): boolean { 166 + if (!err || typeof err !== 'object') return false 167 + const name = 'name' in err && typeof err.name === 'string' ? err.name : '' 168 + if (name === 'TokenRefreshError' || name === 'TokenRevokedError') return true 169 + const message = 'message' in err && typeof err.message === 'string' ? err.message : '' 170 + return /session was deleted|token (has been )?revoked|no refresh token/i.test(message) 171 + } 172 + 173 + /** 174 + * Restore an OAuth session for `did`, or null when the session is gone (see 175 + * `isSessionGone`). Any other failure re-throws so genuine/transient errors 176 + * still surface to the queue's retry. 177 + */ 178 + export async function restoreSessionOrNull(did: string) { 179 + const client = await useOAuthClient() 180 + try { 181 + return await client.restore(did) 182 + } 183 + catch (err) { 184 + if (isSessionGone(err)) return null 185 + throw err 186 + } 187 + } 188 + 156 189 /** Test hook: drop the cached client. */ 157 190 export function clearOAuthClientCache() { 158 191 cachedClient = undefined
+7 -5
server/utils/job-handlers.ts
··· 1 1 import type { OAuthSession } from '@atproto/oauth-client-node' 2 2 import { and, eq, sql } from 'drizzle-orm' 3 3 import { installation, repoMapping, userIdentity } from '../db/schema' 4 - import { useOAuthClient } from './atproto-oauth' 4 + import { restoreSessionOrNull } from './atproto-oauth' 5 5 import { useDb } from './db' 6 6 import { installationOctokit } from './github-app' 7 7 import type { JobEnvelope } from './queue' ··· 203 203 // fail forever on the foreign key. Drop cleanly instead of retrying, and 204 204 // avoid publishing an orphan sh.tangled.publicKey record to the PDS. 205 205 if (!(await installationExists(installationId))) return 206 - const client = await useOAuthClient() 207 - const session = await client.restore(did) 206 + // The user may have revoked the app on their PDS after this job was 207 + // enqueued; the session is then gone and can't be restored. Drop cleanly 208 + // rather than throwing and retrying to exhaustion. 209 + const session = await restoreSessionOrNull(did) 210 + if (!session) return 208 211 if (force) await rotateKey({ oauthSession: session, installationId }) 209 212 else await generateAndPublishKey({ oauthSession: session, installationId }) 210 213 return ··· 386 389 .where(sql`${userIdentity.installationId} = ${installationId}`) 387 390 if (identity.length === 0) return null 388 391 389 - const client = await useOAuthClient() 390 - return client.restore(identity[0]!.did) 392 + return restoreSessionOrNull(identity[0]!.did) 391 393 } 392 394 393 395 /**
+18
test/unit/repository-handler.spec.ts
··· 42 42 43 43 vi.mock('../../server/utils/atproto-oauth', () => ({ 44 44 useOAuthClient: async () => ({ restore: restoreMock }), 45 + restoreSessionOrNull: (did: string) => restoreMock(did), 45 46 })) 46 47 47 48 const { dispatch } = await import('../../server/utils/job-handlers') ··· 344 345 }) 345 346 // Guard short-circuits before restoring the session or publishing a record. 346 347 expect(restoreMock).not.toHaveBeenCalled() 348 + }) 349 + 350 + it('drops the job cleanly when the OAuth session is gone', async () => { 351 + await useDb().insert(installation).values({ 352 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 353 + }) 354 + // restoreSessionOrNull yields null (user revoked the app on their PDS). 355 + restoreMock.mockResolvedValue(null as unknown as { did: string }) 356 + 357 + await expect(dispatch({ 358 + id: 1, 359 + kind: 'atproto.publish-pubkey', 360 + payload: { did: 'did:plc:abc', installationId: 1, force: true }, 361 + attempts: 1, 362 + })).resolves.toBeUndefined() 363 + // No PDS write attempted. 364 + expect(putRecordMock).not.toHaveBeenCalled() 347 365 }) 348 366 })
+19
test/unit/session-gone.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { isSessionGone } from '../../server/utils/atproto-oauth' 3 + 4 + describe('isSessionGone', () => { 5 + it('matches the library errors thrown when a session can no longer be restored', () => { 6 + expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRefreshError' }))).toBe(true) 7 + expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRevokedError' }))).toBe(true) 8 + expect(isSessionGone(new Error('The session was deleted by another process'))).toBe(true) 9 + expect(isSessionGone(new Error('Token has been revoked'))).toBe(true) 10 + expect(isSessionGone(new Error('No refresh token available'))).toBe(true) 11 + }) 12 + 13 + it('does not swallow unrelated errors', () => { 14 + expect(isSessionGone(new Error('network timeout'))).toBe(false) 15 + expect(isSessionGone(new Error('ECONNRESET'))).toBe(false) 16 + expect(isSessionGone(null)).toBe(false) 17 + expect(isSessionGone('a string')).toBe(false) 18 + }) 19 + })