mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

feat: revoke pubkey and stop sync on installation deleted

+125 -3
+4 -2
server/api/github/webhook.post.ts
··· 10 10 import { sql } from 'drizzle-orm' 11 11 import { installation, webhookEvent } from '~~/server/db/schema' 12 12 import { enqueue } from '~~/server/utils/queue' 13 + import { revokeKeysForInstallation } from '~~/server/utils/tangled-pubkey' 13 14 14 15 const RECOGNISED_EVENTS = new Set([ 15 16 'push', ··· 86 87 }).onConflictDoNothing({ target: installation.id }) 87 88 } 88 89 else if (action === 'deleted') { 90 + // Revoke the user's sh.tangled.publicKey PDS records first; the cascade 91 + // below drops the local ssh_key rows that hold the rkeys we need. 92 + await revokeKeysForInstallation(body.installation.id) 89 93 // installation row deletion cascades to user_identity, ssh_key, repo_mapping. 90 - // The corresponding sh.tangled.publicKey record on the user's PDS is revoked 91 - // in commit 15. 92 94 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`) 93 95 } 94 96 else if (action === 'suspend') {
+43
server/utils/tangled-pubkey.ts
··· 2 2 import type { OAuthSession } from '@atproto/oauth-client-node' 3 3 import { sql } from 'drizzle-orm' 4 4 import { sshKey } from '../db/schema' 5 + import { useOAuthClient } from './atproto-oauth' 5 6 import { useDb } from './db' 6 7 import { encrypt } from './encryption' 7 8 import { generateKeypair } from './ssh-keypair' ··· 115 116 116 117 return generateAndPublishKey(opts) 117 118 } 119 + 120 + /** 121 + * Delete every `sh.tangled.publicKey` record we published for an installation 122 + * from the owning user's PDS. 123 + * 124 + * Called on `installation.deleted` *before* the local cascade delete, so the 125 + * user isn't left with a dead key to clean up by hand. Restoring the OAuth 126 + * session can fail if the user revoked the app on their PDS before uninstalling 127 + * the GitHub App; in that case there's nothing for us to delete, so we swallow 128 + * the error and let the caller proceed with the local cascade. A 404 on the 129 + * delete itself is likewise treated as already-gone. 130 + */ 131 + export async function revokeKeysForInstallation(installationId: number): Promise<void> { 132 + const db = useDb() 133 + const rows = await db.select({ did: sshKey.did, rkey: sshKey.tangledKeyRkey }) 134 + .from(sshKey) 135 + .where(sql`${sshKey.installationId} = ${installationId}`) 136 + 137 + const client = await useOAuthClient() 138 + 139 + for (const row of rows) { 140 + if (!row.rkey) continue 141 + try { 142 + // eslint-disable-next-line no-await-in-loop -- one PDS session per row 143 + const session = await client.restore(row.did) 144 + const agent = new Agent(session) 145 + // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes 146 + await agent.com.atproto.repo.deleteRecord({ 147 + repo: row.did, 148 + collection: PUBKEY_LEXICON, 149 + rkey: row.rkey, 150 + }) 151 + } 152 + catch (err) { 153 + const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 154 + ? err.status 155 + : undefined 156 + if (status === 404) continue 157 + console.error(`failed to revoke publicKey record for did ${row.did} (installation ${installationId})`, err) 158 + } 159 + } 160 + }
+78 -1
test/unit/tangled-pubkey.spec.ts
··· 4 4 import { installation, sshKey } from '../../server/db/schema' 5 5 import { clearDb, setDb, useDb } from '../../server/utils/db' 6 6 import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption' 7 - import { generateAndPublishKey, rotateKey } from '../../server/utils/tangled-pubkey' 8 7 import { createTestDb } from '../utils/db' 9 8 10 9 const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 11 10 12 11 const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>() 13 12 const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>() 13 + const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>() 14 14 15 15 vi.mock('@atproto/api', () => ({ 16 16 Agent: class { ··· 24 24 } 25 25 }, 26 26 })) 27 + 28 + vi.mock('../../server/utils/atproto-oauth', () => ({ 29 + useOAuthClient: async () => ({ restore: restoreMock }), 30 + })) 31 + 32 + const { generateAndPublishKey, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') 27 33 28 34 function fakeOauthSession(did: string) { 29 35 // The Agent mock above ignores its constructor argument, so we only need ··· 232 238 expect(rows).toHaveLength(1) 233 239 }) 234 240 }) 241 + 242 + describe('revokeKeysForInstallation', () => { 243 + beforeEach(async () => { 244 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 245 + clearEncryptionKeyCache() 246 + 247 + setDb(await createTestDb()) 248 + await useDb().insert(installation).values({ 249 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 250 + }) 251 + 252 + createRecordMock.mockReset() 253 + deleteRecordMock.mockReset() 254 + restoreMock.mockReset() 255 + createRecordMock.mockResolvedValue({ 256 + data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 257 + }) 258 + deleteRecordMock.mockResolvedValue({}) 259 + restoreMock.mockImplementation(async (did: string) => ({ did })) 260 + }) 261 + 262 + afterEach(() => { 263 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 264 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 265 + clearEncryptionKeyCache() 266 + clearDb() 267 + }) 268 + 269 + it('deletes the publicKey PDS record for the installation', async () => { 270 + await generateAndPublishKey({ 271 + oauthSession: fakeOauthSession('did:plc:abc'), 272 + installationId: 1, 273 + }) 274 + 275 + await revokeKeysForInstallation(1) 276 + 277 + expect(restoreMock).toHaveBeenCalledWith('did:plc:abc') 278 + expect(deleteRecordMock).toHaveBeenCalledTimes(1) 279 + const del = deleteRecordMock.mock.calls[0][0] 280 + expect(del.repo).toBe('did:plc:abc') 281 + expect(del.collection).toBe('sh.tangled.publicKey') 282 + expect(del.rkey).toBe('3kh2y4xq2lk2v') 283 + }) 284 + 285 + it('no-ops when the installation has no keys', async () => { 286 + await revokeKeysForInstallation(1) 287 + expect(restoreMock).not.toHaveBeenCalled() 288 + expect(deleteRecordMock).not.toHaveBeenCalled() 289 + }) 290 + 291 + it('swallows a 404 from the PDS delete', async () => { 292 + await generateAndPublishKey({ 293 + oauthSession: fakeOauthSession('did:plc:abc'), 294 + installationId: 1, 295 + }) 296 + deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('not found'), { status: 404 })) 297 + 298 + await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined() 299 + }) 300 + 301 + it('continues when OAuth session restoration fails', async () => { 302 + await generateAndPublishKey({ 303 + oauthSession: fakeOauthSession('did:plc:abc'), 304 + installationId: 1, 305 + }) 306 + restoreMock.mockRejectedValueOnce(new Error('session gone')) 307 + 308 + await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined() 309 + expect(deleteRecordMock).not.toHaveBeenCalled() 310 + }) 311 + })