mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import { Agent } from '@atproto/api' 2import type { OAuthSession } from '@atproto/oauth-client-node' 3import { sql } from 'drizzle-orm' 4import { sshKey } from '../db/schema' 5import { useOAuthClient } from './atproto-oauth' 6import { useDb } from './db' 7import { encrypt } from './encryption' 8import { generateKeypair } from './ssh-keypair' 9 10const PUBKEY_LEXICON = 'sh.tangled.publicKey' 11 12/** 13 * Generate a per-install SSH keypair, write the public half to the user's PDS 14 * as a `sh.tangled.publicKey` record, and persist the encrypted private half 15 * + the resulting record key in the `ssh_key` table. 16 * 17 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a 18 * separate, explicit dashboard action (commit 16-ish) that re-runs this with 19 * the existing record then deletes the old one. 20 */ 21export async function generateAndPublishKey(opts: { 22 oauthSession: OAuthSession 23 installationId: number 24 keyName?: string 25}): Promise<{ created: boolean }> { 26 const db = useDb() 27 const did = opts.oauthSession.did 28 29 const existing = await db.select({ id: sshKey.id }) 30 .from(sshKey) 31 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) 32 if (existing.length > 0) { 33 return { created: false } 34 } 35 36 const keyName = opts.keyName ?? `synchub.to/${opts.installationId}` 37 const keypair = generateKeypair(keyName) 38 39 // Publish to PDS first. If this fails, we surface the error and leave no 40 // half-state in the DB \u2014 the caller can retry. 41 const agent = new Agent(opts.oauthSession) 42 const result = await agent.com.atproto.repo.createRecord({ 43 repo: did, 44 collection: PUBKEY_LEXICON, 45 record: { 46 $type: PUBKEY_LEXICON, 47 key: keypair.publicKeyOpenSsh, 48 name: keyName, 49 createdAt: new Date().toISOString(), 50 }, 51 }) 52 53 // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`). 54 const rkey = result.data.uri.split('/').pop() 55 if (!rkey) { 56 throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`) 57 } 58 59 const { ciphertext, nonce } = encrypt(keypair.privateKeyPem) 60 await db.insert(sshKey).values({ 61 installationId: opts.installationId, 62 did, 63 publicKey: keypair.publicKeyOpenSsh, 64 privateKeyCiphertext: ciphertext, 65 privateKeyNonce: nonce, 66 tangledKeyRkey: rkey, 67 }) 68 69 return { created: true } 70} 71 72/** 73 * Rotate the SSH key for `(installationId, did)`. 74 * 75 * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the 76 * record is already gone on the PDS we proceed), drop the local row, then 77 * fall through to `generateAndPublishKey` to mint a fresh key. Pushes 78 * already in flight with the old key will fail and get retried with the new 79 * one via the queue's normal backoff. 80 */ 81export async function rotateKey(opts: { 82 oauthSession: OAuthSession 83 installationId: number 84 keyName?: string 85}): Promise<{ created: boolean }> { 86 const db = useDb() 87 const did = opts.oauthSession.did 88 89 const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) 90 .from(sshKey) 91 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) 92 93 if (existing.length > 0) { 94 const row = existing[0]! 95 if (row.rkey) { 96 const agent = new Agent(opts.oauthSession) 97 try { 98 await agent.com.atproto.repo.deleteRecord({ 99 repo: did, 100 collection: PUBKEY_LEXICON, 101 rkey: row.rkey, 102 }) 103 } 104 catch (err) { 105 // If the record is already gone (404) we can safely continue; any 106 // other error means the PDS rejected the delete and we should bail 107 // rather than leave the user with two records. 108 const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 109 ? err.status 110 : undefined 111 if (status !== 404) throw err 112 } 113 } 114 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 115 } 116 117 return generateAndPublishKey(opts) 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 */ 131export 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}