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