mirror your GitHub repos to tangled.org automatically
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})