···153153 }
154154}
155155156156+/**
157157+ * True when restoring an OAuth session failed because the session no longer
158158+ * exists or can't be refreshed: the user revoked the app on their PDS, the
159159+ * refresh token expired, or the session row was never written / already
160160+ * deleted. The library signals all of these by throwing `TokenRefreshError` /
161161+ * `TokenRevokedError`; we also match the message for resilience across library
162162+ * versions. Callers treat this as a benign drop rather than a retryable error,
163163+ * so per-DID work doesn't loop forever once a user disconnects.
164164+ */
165165+export function isSessionGone(err: unknown): boolean {
166166+ if (!err || typeof err !== 'object') return false
167167+ const name = 'name' in err && typeof err.name === 'string' ? err.name : ''
168168+ if (name === 'TokenRefreshError' || name === 'TokenRevokedError') return true
169169+ const message = 'message' in err && typeof err.message === 'string' ? err.message : ''
170170+ return /session was deleted|token (has been )?revoked|no refresh token/i.test(message)
171171+}
172172+173173+/**
174174+ * Restore an OAuth session for `did`, or null when the session is gone (see
175175+ * `isSessionGone`). Any other failure re-throws so genuine/transient errors
176176+ * still surface to the queue's retry.
177177+ */
178178+export async function restoreSessionOrNull(did: string) {
179179+ const client = await useOAuthClient()
180180+ try {
181181+ return await client.restore(did)
182182+ }
183183+ catch (err) {
184184+ if (isSessionGone(err)) return null
185185+ throw err
186186+ }
187187+}
188188+156189/** Test hook: drop the cached client. */
157190export function clearOAuthClientCache() {
158191 cachedClient = undefined
+7-5
server/utils/job-handlers.ts
···11import type { OAuthSession } from '@atproto/oauth-client-node'
22import { and, eq, sql } from 'drizzle-orm'
33import { installation, repoMapping, userIdentity } from '../db/schema'
44-import { useOAuthClient } from './atproto-oauth'
44+import { restoreSessionOrNull } from './atproto-oauth'
55import { useDb } from './db'
66import { installationOctokit } from './github-app'
77import type { JobEnvelope } from './queue'
···203203 // fail forever on the foreign key. Drop cleanly instead of retrying, and
204204 // avoid publishing an orphan sh.tangled.publicKey record to the PDS.
205205 if (!(await installationExists(installationId))) return
206206- const client = await useOAuthClient()
207207- const session = await client.restore(did)
206206+ // The user may have revoked the app on their PDS after this job was
207207+ // enqueued; the session is then gone and can't be restored. Drop cleanly
208208+ // rather than throwing and retrying to exhaustion.
209209+ const session = await restoreSessionOrNull(did)
210210+ if (!session) return
208211 if (force) await rotateKey({ oauthSession: session, installationId })
209212 else await generateAndPublishKey({ oauthSession: session, installationId })
210213 return
···386389 .where(sql`${userIdentity.installationId} = ${installationId}`)
387390 if (identity.length === 0) return null
388391389389- const client = await useOAuthClient()
390390- return client.restore(identity[0]!.did)
392392+ return restoreSessionOrNull(identity[0]!.did)
391393}
392394393395/**
+18
test/unit/repository-handler.spec.ts
···42424343vi.mock('../../server/utils/atproto-oauth', () => ({
4444 useOAuthClient: async () => ({ restore: restoreMock }),
4545+ restoreSessionOrNull: (did: string) => restoreMock(did),
4546}))
46474748const { dispatch } = await import('../../server/utils/job-handlers')
···344345 })
345346 // Guard short-circuits before restoring the session or publishing a record.
346347 expect(restoreMock).not.toHaveBeenCalled()
348348+ })
349349+350350+ it('drops the job cleanly when the OAuth session is gone', async () => {
351351+ await useDb().insert(installation).values({
352352+ id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
353353+ })
354354+ // restoreSessionOrNull yields null (user revoked the app on their PDS).
355355+ restoreMock.mockResolvedValue(null as unknown as { did: string })
356356+357357+ await expect(dispatch({
358358+ id: 1,
359359+ kind: 'atproto.publish-pubkey',
360360+ payload: { did: 'did:plc:abc', installationId: 1, force: true },
361361+ attempts: 1,
362362+ })).resolves.toBeUndefined()
363363+ // No PDS write attempted.
364364+ expect(putRecordMock).not.toHaveBeenCalled()
347365 })
348366})
+19
test/unit/session-gone.spec.ts
···11+import { describe, expect, it } from 'vitest'
22+import { isSessionGone } from '../../server/utils/atproto-oauth'
33+44+describe('isSessionGone', () => {
55+ it('matches the library errors thrown when a session can no longer be restored', () => {
66+ expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRefreshError' }))).toBe(true)
77+ expect(isSessionGone(Object.assign(new Error('whatever'), { name: 'TokenRevokedError' }))).toBe(true)
88+ expect(isSessionGone(new Error('The session was deleted by another process'))).toBe(true)
99+ expect(isSessionGone(new Error('Token has been revoked'))).toBe(true)
1010+ expect(isSessionGone(new Error('No refresh token available'))).toBe(true)
1111+ })
1212+1313+ it('does not swallow unrelated errors', () => {
1414+ expect(isSessionGone(new Error('network timeout'))).toBe(false)
1515+ expect(isSessionGone(new Error('ECONNRESET'))).toBe(false)
1616+ expect(isSessionGone(null)).toBe(false)
1717+ expect(isSessionGone('a string')).toBe(false)
1818+ })
1919+})