mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

at main 6.9 kB View raw
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/** HTTP status off an unknown thrown value, or undefined if it has none. */ 13function errorStatus(err: unknown): number | undefined { 14 return err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 15 ? err.status 16 : undefined 17} 18 19/** 20 * Delete one `sh.tangled.publicKey` record from `did`'s PDS, treating an 21 * already-gone record (404) as success. Any other PDS error re-throws. 22 */ 23async function deletePubkeyRecord(session: OAuthSession, did: string, rkey: string): Promise<void> { 24 const agent = new Agent(session) 25 try { 26 await agent.com.atproto.repo.deleteRecord({ repo: did, collection: PUBKEY_LEXICON, rkey }) 27 } 28 catch (err) { 29 if (errorStatus(err) !== 404) throw err 30 } 31} 32 33/** 34 * Restore `did`'s OAuth session and delete one `sh.tangled.publicKey` record, 35 * never throwing: a 404 is already-gone, and any other failure (including a 36 * session that can no longer be restored) is logged so the caller's cleanup 37 * proceeds. 38 */ 39async function bestEffortRevoke(did: string, rkey: string, installationId: number): Promise<void> { 40 const client = await useOAuthClient() 41 try { 42 const session = await client.restore(did) 43 await deletePubkeyRecord(session, did, rkey) 44 } 45 catch (err) { 46 console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) 47 } 48} 49 50/** 51 * Generate a per-install SSH keypair, write the public half to the user's PDS 52 * as a `sh.tangled.publicKey` record, and persist the encrypted private half 53 * + the resulting record key in the `ssh_key` table. 54 * 55 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a 56 * separate, explicit dashboard action (`rotateKey`) that deletes the existing 57 * record then re-runs this. 58 */ 59export async function generateAndPublishKey(opts: { 60 oauthSession: OAuthSession 61 installationId: number 62 keyName?: string 63}): Promise<{ created: boolean }> { 64 const db = useDb() 65 const did = opts.oauthSession.did 66 67 const existing = await db.select({ id: sshKey.id }) 68 .from(sshKey) 69 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) 70 if (existing.length > 0) { 71 return { created: false } 72 } 73 74 const keyName = opts.keyName ?? `synchub.to/${opts.installationId}` 75 const keypair = generateKeypair(keyName) 76 77 // Publish to PDS first. If this fails, we surface the error and leave no 78 // half-state in the DB; the caller can retry. 79 const agent = new Agent(opts.oauthSession) 80 const result = await agent.com.atproto.repo.createRecord({ 81 repo: did, 82 collection: PUBKEY_LEXICON, 83 record: { 84 $type: PUBKEY_LEXICON, 85 key: keypair.publicKeyOpenSsh, 86 name: keyName, 87 createdAt: new Date().toISOString(), 88 }, 89 }) 90 91 // Extract the rkey from the returned at-uri (`at://<did>/<collection>/<rkey>`). 92 const rkey = result.data.uri.split('/').pop() 93 if (!rkey) { 94 throw new Error(`could not parse rkey from publicKey record uri: ${result.data.uri}`) 95 } 96 97 const { ciphertext, nonce } = encrypt(keypair.privateKeyPem) 98 await db.insert(sshKey).values({ 99 installationId: opts.installationId, 100 did, 101 publicKey: keypair.publicKeyOpenSsh, 102 privateKeyCiphertext: ciphertext, 103 privateKeyNonce: nonce, 104 tangledKeyRkey: rkey, 105 }) 106 107 return { created: true } 108} 109 110/** 111 * Rotate the SSH key for `(installationId, did)`. 112 * 113 * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the 114 * record is already gone on the PDS we proceed), drop the local row, then 115 * fall through to `generateAndPublishKey` to mint a fresh key. Pushes 116 * already in flight with the old key will fail and get retried with the new 117 * one via the queue's normal backoff. 118 */ 119export async function rotateKey(opts: { 120 oauthSession: OAuthSession 121 installationId: number 122 keyName?: string 123}): Promise<{ created: boolean }> { 124 const db = useDb() 125 const did = opts.oauthSession.did 126 127 const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) 128 .from(sshKey) 129 .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) 130 131 if (existing.length > 0) { 132 const row = existing[0]! 133 if (row.rkey) { 134 await deletePubkeyRecord(opts.oauthSession, did, row.rkey) 135 } 136 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 137 } 138 139 return generateAndPublishKey(opts) 140} 141 142/** 143 * Delete every `sh.tangled.publicKey` record we published for an installation 144 * from the owning user's PDS. 145 * 146 * Called on `installation.deleted` *before* the local cascade delete, so the 147 * user isn't left with a dead key to clean up by hand. Restoring the OAuth 148 * session can fail if the user revoked the app on their PDS before uninstalling 149 * the GitHub App; in that case there's nothing for us to delete, so we swallow 150 * the error and let the caller proceed with the local cascade. A 404 on the 151 * delete itself is likewise treated as already-gone. 152 */ 153export async function revokeKeysForInstallation(installationId: number): Promise<void> { 154 const db = useDb() 155 const rows = await db.select({ did: sshKey.did, rkey: sshKey.tangledKeyRkey }) 156 .from(sshKey) 157 .where(sql`${sshKey.installationId} = ${installationId}`) 158 159 for (const row of rows) { 160 if (!row.rkey) continue 161 // eslint-disable-next-line no-await-in-loop -- one PDS session per row 162 await bestEffortRevoke(row.did, row.rkey, installationId) 163 } 164} 165 166/** 167 * Revoke the `sh.tangled.publicKey` PDS record for one `(installationId, did)` 168 * pair and drop its local `ssh_key` row. 169 * 170 * Used when a re-bind displaces a DID from an installation: the displaced 171 * DID's key is now dead for that account, so we revoke it from their PDS and 172 * delete the local row. Best-effort on the PDS side (a 404 or a failed 173 * session restore is logged, not fatal) so the re-bind always completes; the 174 * local row is dropped regardless. 175 */ 176export async function revokeKeyForInstallationDid(installationId: number, did: string): Promise<void> { 177 const db = useDb() 178 const rows = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) 179 .from(sshKey) 180 .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`) 181 182 for (const row of rows) { 183 if (row.rkey) { 184 // eslint-disable-next-line no-await-in-loop -- one PDS session per row 185 await bestEffortRevoke(did, row.rkey, installationId) 186 } 187 // eslint-disable-next-line no-await-in-loop -- sequential row deletes 188 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 189 } 190}