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 { 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})