diff --git a/server/api/github/webhook.post.ts b/server/api/github/webhook.post.ts index 0428bef..4032658 100644 --- a/server/api/github/webhook.post.ts +++ b/server/api/github/webhook.post.ts @@ -8,7 +8,7 @@ import type { } from '@octokit/webhooks-types' import { verify } from '@octokit/webhooks-methods' import { sql } from 'drizzle-orm' -import { installation, webhookEvent } from '#server/db/schema' +import { installation, job, webhookEvent } from '#server/db/schema' import { kickWorker } from '#server/utils/kick-worker' import { enqueue } from '#server/utils/queue' import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey' @@ -91,6 +91,15 @@ export default defineEventHandler(async event => { // Revoke the user's sh.tangled.publicKey PDS records first; the cascade // below drops the local ssh_key rows that hold the rkeys we need. await revokeKeysForInstallation(body.installation.id) + // Cancel any still-pending work for this install. The `job` table has no + // FK to `installation` (payload carries the id as JSON), so nothing + // cascades; left alone, these jobs retry to exhaustion failing on the + // now-missing FK target (e.g. the ssh_key insert). Drop only unstarted + // work; a running job finishes and no-ops on its own guards. + await db.delete(job).where(sql` + ${job.status} in ('queued', 'failed') + AND (${job.payload}->>'installationId')::bigint = ${body.installation.id} + `) // installation row deletion cascades to user_identity, ssh_key, repo_mapping. await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`) } diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index d95d4ed..e72d9dd 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -1,6 +1,6 @@ import type { OAuthSession } from '@atproto/oauth-client-node' import { and, eq, sql } from 'drizzle-orm' -import { repoMapping, userIdentity } from '../db/schema' +import { installation, repoMapping, userIdentity } from '../db/schema' import { useOAuthClient } from './atproto-oauth' import { useDb } from './db' import { installationOctokit } from './github-app' @@ -198,6 +198,11 @@ export async function dispatch(envelope: JobEnvelope): Promise { if (envelope.kind === 'atproto.publish-pubkey') { const { did, installationId, force } = publishPubkeyPayload(envelope.payload) + // The install may have been uninstalled after this job was enqueued; its + // row (and any ssh_key FK target) is then gone, so the key insert would + // fail forever on the foreign key. Drop cleanly instead of retrying, and + // avoid publishing an orphan sh.tangled.publicKey record to the PDS. + if (!(await installationExists(installationId))) return const client = await useOAuthClient() const session = await client.restore(did) if (force) await rotateKey({ oauthSession: session, installationId }) @@ -384,3 +389,18 @@ async function restoreSessionForInstallation(installationId: number): Promise { + const db = useDb() + const rows = await db.select({ id: installation.id }) + .from(installation) + .where(sql`${installation.id} = ${installationId}`) + .limit(1) + return rows.length > 0 +} diff --git a/test/unit/repository-handler.spec.ts b/test/unit/repository-handler.spec.ts index d3e4d95..e1cd679 100644 --- a/test/unit/repository-handler.spec.ts +++ b/test/unit/repository-handler.spec.ts @@ -317,3 +317,32 @@ describe('github.installation_repositories removed', () => { expect(byRepoId[9002]?.disabledAt).toBeNull() }) }) + +describe('atproto.publish-pubkey install-existence guard', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + setDb(await createTestDb()) + restoreMock.mockReset() + restoreMock.mockResolvedValue({ did: 'did:plc:abc' }) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + it('drops the job without touching the PDS when the installation is gone', async () => { + // No installation row: models a job enqueued before the user uninstalled. + await dispatch({ + id: 1, + kind: 'atproto.publish-pubkey', + payload: { did: 'did:plc:abc', installationId: 999, force: true }, + attempts: 1, + }) + // Guard short-circuits before restoring the session or publishing a record. + expect(restoreMock).not.toHaveBeenCalled() + }) +})