···88} from '@octokit/webhooks-types'
99import { verify } from '@octokit/webhooks-methods'
1010import { sql } from 'drizzle-orm'
1111-import { installation, webhookEvent } from '#server/db/schema'
1111+import { installation, job, webhookEvent } from '#server/db/schema'
1212import { kickWorker } from '#server/utils/kick-worker'
1313import { enqueue } from '#server/utils/queue'
1414import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey'
···9191 // Revoke the user's sh.tangled.publicKey PDS records first; the cascade
9292 // below drops the local ssh_key rows that hold the rkeys we need.
9393 await revokeKeysForInstallation(body.installation.id)
9494+ // Cancel any still-pending work for this install. The `job` table has no
9595+ // FK to `installation` (payload carries the id as JSON), so nothing
9696+ // cascades; left alone, these jobs retry to exhaustion failing on the
9797+ // now-missing FK target (e.g. the ssh_key insert). Drop only unstarted
9898+ // work; a running job finishes and no-ops on its own guards.
9999+ await db.delete(job).where(sql`
100100+ ${job.status} in ('queued', 'failed')
101101+ AND (${job.payload}->>'installationId')::bigint = ${body.installation.id}
102102+ `)
94103 // installation row deletion cascades to user_identity, ssh_key, repo_mapping.
95104 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`)
96105 }
+21-1
server/utils/job-handlers.ts
···11import type { OAuthSession } from '@atproto/oauth-client-node'
22import { and, eq, sql } from 'drizzle-orm'
33-import { repoMapping, userIdentity } from '../db/schema'
33+import { installation, repoMapping, userIdentity } from '../db/schema'
44import { useOAuthClient } from './atproto-oauth'
55import { useDb } from './db'
66import { installationOctokit } from './github-app'
···198198199199 if (envelope.kind === 'atproto.publish-pubkey') {
200200 const { did, installationId, force } = publishPubkeyPayload(envelope.payload)
201201+ // The install may have been uninstalled after this job was enqueued; its
202202+ // row (and any ssh_key FK target) is then gone, so the key insert would
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
201206 const client = await useOAuthClient()
202207 const session = await client.restore(did)
203208 if (force) await rotateKey({ oauthSession: session, installationId })
···384389 const client = await useOAuthClient()
385390 return client.restore(identity[0]!.did)
386391}
392392+393393+/**
394394+ * Whether the `installation` row still exists. Jobs enqueued before an
395395+ * uninstall can outlive the row (which cascade-deletes user_identity, ssh_key,
396396+ * repo_mapping); handlers use this to drop such orphaned work cleanly rather
397397+ * than failing on a foreign-key violation.
398398+ */
399399+async function installationExists(installationId: number): Promise<boolean> {
400400+ const db = useDb()
401401+ const rows = await db.select({ id: installation.id })
402402+ .from(installation)
403403+ .where(sql`${installation.id} = ${installationId}`)
404404+ .limit(1)
405405+ return rows.length > 0
406406+}
+29
test/unit/repository-handler.spec.ts
···317317 expect(byRepoId[9002]?.disabledAt).toBeNull()
318318 })
319319})
320320+321321+describe('atproto.publish-pubkey install-existence guard', () => {
322322+ beforeEach(async () => {
323323+ process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
324324+ clearEncryptionKeyCache()
325325+ setDb(await createTestDb())
326326+ restoreMock.mockReset()
327327+ restoreMock.mockResolvedValue({ did: 'did:plc:abc' })
328328+ })
329329+330330+ afterEach(() => {
331331+ if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
332332+ else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
333333+ clearEncryptionKeyCache()
334334+ clearDb()
335335+ })
336336+337337+ it('drops the job without touching the PDS when the installation is gone', async () => {
338338+ // No installation row: models a job enqueued before the user uninstalled.
339339+ await dispatch({
340340+ id: 1,
341341+ kind: 'atproto.publish-pubkey',
342342+ payload: { did: 'did:plc:abc', installationId: 999, force: true },
343343+ attempts: 1,
344344+ })
345345+ // Guard short-circuits before restoring the session or publishing a record.
346346+ expect(restoreMock).not.toHaveBeenCalled()
347347+ })
348348+})