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