mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import { and, eq, ne } from 'drizzle-orm' 2import { userIdentity } from '#server/db/schema' 3import { enqueue } from '#server/utils/queue' 4import { resolveHandle } from '#server/utils/resolve-handle' 5import { addAccount } from '#server/utils/server-session' 6import { generateAndPublishKey, revokeKeyForInstallationDid } from '#server/utils/tangled-pubkey' 7 8export default defineEventHandler(async event => { 9 const url = getRequestURL(event) 10 const params = url.searchParams 11 12 const client = await useOAuthClient() 13 const { session, state } = await client.callback(params) 14 15 const db = useDb() 16 17 // Two flows land here: 18 // 19 // - **First-time connect** (`state` set to the GitHub installation id). 20 // Bind the resulting `user_identity` row, publish an SSH key, kick off 21 // repo backfill. This is the "install GitHub App → connect tangled" 22 // ordering. 23 // - **Returning sign-in** (`state` absent). The user already has a 24 // `user_identity` row; we look it up by DID and write a fresh session 25 // so they can use the dashboard on a new device / browser. 26 // 27 // If `state` is present but invalid, or `state` is absent but no 28 // `user_identity` exists for the DID, we send the user to install the 29 // GitHub App. Trying to land them on `/dashboard` with no installation 30 // would just show a useless page. 31 32 let installationId: number | undefined 33 let handle: string | null = null 34 35 if (state) { 36 const parsed = Number(state) 37 if (!Number.isFinite(parsed)) { 38 throw createError({ statusCode: 400, statusMessage: 'invalid state (non-numeric installation id)' }) 39 } 40 installationId = parsed 41 42 // One installation maps to exactly one DID. If another DID is currently 43 // bound to this installation, this connect displaces it: revoke that DID's 44 // now-dead SSH key (PDS record + local row) and null its installationId so 45 // the worker stops syncing for it. The displaced user_identity row is 46 // preserved — the user keeps their identity and can re-bind elsewhere. 47 const displaced = await db.select({ did: userIdentity.did }) 48 .from(userIdentity) 49 .where(and( 50 eq(userIdentity.installationId, installationId), 51 ne(userIdentity.did, session.did), 52 )) 53 54 for (const row of displaced) { 55 // eslint-disable-next-line no-await-in-loop -- sequential PDS revocations 56 await revokeKeyForInstallationDid(installationId, row.did) 57 } 58 59 if (displaced.length > 0) { 60 await db.update(userIdentity) 61 .set({ installationId: null, updatedAt: new Date() }) 62 .where(and( 63 eq(userIdentity.installationId, installationId), 64 ne(userIdentity.did, session.did), 65 )) 66 } 67 68 handle = await resolveHandle(session) 69 70 await db.insert(userIdentity).values({ 71 did: session.did, 72 handle, 73 installationId, 74 updatedAt: new Date(), 75 }).onConflictDoUpdate({ 76 target: userIdentity.did, 77 set: { installationId, handle, updatedAt: new Date() }, 78 }) 79 80 // Generate and publish the SSH key inline: it's one ed25519 keygen + one 81 // PDS write, well under the function timeout, and lets us land users on 82 // the dashboard already enrolled. Rotation is a separate dashboard 83 // action that goes via the queue. 84 await generateAndPublishKey({ oauthSession: session, installationId }) 85 86 // Backfill: enqueue a single job that walks the installation's repo list 87 // and fans out per-repo enrolment. Doing this in the worker (rather than 88 // inline here) keeps the OAuth callback fast regardless of repo count. 89 await enqueue('tangled.backfill-installation', { installationId, page: 1 }) 90 } 91 else { 92 // Returning sign-in. Look up the installation we previously bound. 93 const rows = await db.select({ 94 installationId: userIdentity.installationId, 95 handle: userIdentity.handle, 96 }) 97 .from(userIdentity) 98 .where(eq(userIdentity.did, session.did)) 99 .limit(1) 100 101 if (rows.length === 0 || rows[0]!.installationId === null) { 102 // Authenticated tangled identity, but no GitHub install bound. Send 103 // them to install the App. We deliberately do NOT write a session 104 // yet — the dashboard needs an installation to be useful. 105 const appInstallUrl = process.env.NUXT_GITHUB_APP_INSTALL_URL 106 ?? 'https://github.com/apps/synchub-to/installations/new' 107 await sendRedirect(event, appInstallUrl, 302) 108 return 109 } 110 installationId = rows[0]!.installationId 111 112 // Refresh the handle on sign-in: it can change on the user's PDS between 113 // visits. Fall back to the stored value if resolution fails. 114 handle = await resolveHandle(session) ?? rows[0]!.handle 115 if (handle !== rows[0]!.handle) { 116 await db.update(userIdentity) 117 .set({ handle, updatedAt: new Date() }) 118 .where(eq(userIdentity.did, session.did)) 119 } 120 } 121 122 // Append this account to the device's session and make it active. A user can 123 // be connected to several handles at once and switch between them locally. 124 await addAccount(event, { did: session.did, installationId, handle: handle ?? undefined }) 125 126 await sendRedirect(event, '/dashboard', 302) 127})