···1010import { sql } from 'drizzle-orm'
1111import { installation, webhookEvent } from '~~/server/db/schema'
1212import { enqueue } from '~~/server/utils/queue'
1313+import { revokeKeysForInstallation } from '~~/server/utils/tangled-pubkey'
13141415const RECOGNISED_EVENTS = new Set([
1516 'push',
···8687 }).onConflictDoNothing({ target: installation.id })
8788 }
8889 else if (action === 'deleted') {
9090+ // Revoke the user's sh.tangled.publicKey PDS records first; the cascade
9191+ // below drops the local ssh_key rows that hold the rkeys we need.
9292+ await revokeKeysForInstallation(body.installation.id)
8993 // installation row deletion cascades to user_identity, ssh_key, repo_mapping.
9090- // The corresponding sh.tangled.publicKey record on the user's PDS is revoked
9191- // in commit 15.
9294 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`)
9395 }
9496 else if (action === 'suspend') {
+43
server/utils/tangled-pubkey.ts
···22import type { OAuthSession } from '@atproto/oauth-client-node'
33import { sql } from 'drizzle-orm'
44import { sshKey } from '../db/schema'
55+import { useOAuthClient } from './atproto-oauth'
56import { useDb } from './db'
67import { encrypt } from './encryption'
78import { generateKeypair } from './ssh-keypair'
···115116116117 return generateAndPublishKey(opts)
117118}
119119+120120+/**
121121+ * Delete every `sh.tangled.publicKey` record we published for an installation
122122+ * from the owning user's PDS.
123123+ *
124124+ * Called on `installation.deleted` *before* the local cascade delete, so the
125125+ * user isn't left with a dead key to clean up by hand. Restoring the OAuth
126126+ * session can fail if the user revoked the app on their PDS before uninstalling
127127+ * the GitHub App; in that case there's nothing for us to delete, so we swallow
128128+ * the error and let the caller proceed with the local cascade. A 404 on the
129129+ * delete itself is likewise treated as already-gone.
130130+ */
131131+export async function revokeKeysForInstallation(installationId: number): Promise<void> {
132132+ const db = useDb()
133133+ const rows = await db.select({ did: sshKey.did, rkey: sshKey.tangledKeyRkey })
134134+ .from(sshKey)
135135+ .where(sql`${sshKey.installationId} = ${installationId}`)
136136+137137+ const client = await useOAuthClient()
138138+139139+ for (const row of rows) {
140140+ if (!row.rkey) continue
141141+ try {
142142+ // eslint-disable-next-line no-await-in-loop -- one PDS session per row
143143+ const session = await client.restore(row.did)
144144+ const agent = new Agent(session)
145145+ // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes
146146+ await agent.com.atproto.repo.deleteRecord({
147147+ repo: row.did,
148148+ collection: PUBKEY_LEXICON,
149149+ rkey: row.rkey,
150150+ })
151151+ }
152152+ catch (err) {
153153+ const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
154154+ ? err.status
155155+ : undefined
156156+ if (status === 404) continue
157157+ console.error(`failed to revoke publicKey record for did ${row.did} (installation ${installationId})`, err)
158158+ }
159159+ }
160160+}