mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

feat: add sign-in flow for returning visitors

+199 -41
+6
.env.example
··· 61 61 -----END RSA PRIVATE KEY----- 62 62 " 63 63 64 + # URL for installing the GitHub App. Used to redirect returning sign-ins that 65 + # have an authenticated tangled identity but no GitHub install bound yet. 66 + # Find it on your GitHub App's "Public page" link, in the form 67 + # `https://github.com/apps/<app-slug>/installations/new`. 68 + NUXT_GITHUB_APP_INSTALL_URL=https://github.com/apps/synchub-to/installations/new 69 + 64 70 # --------------------------------------------------------------------------- 65 71 # Cron secret — protects the worker tick endpoint (`/api/jobs/run`) from 66 72 # unauthenticated callers. In prod, Vercel Cron sends this automatically;
+112 -2
app/pages/index.vue
··· 1 + <script setup lang="ts"> 2 + // If already signed in, bounce to the dashboard. 3 + const { data: session } = await useFetch('/api/me/whoami', { 4 + // Don't blow up the page on the (very common) 401. 5 + ignoreResponseError: true, 6 + }) 7 + 8 + if (session.value && typeof session.value === 'object' && 'did' in session.value) { 9 + await navigateTo('/dashboard') 10 + } 11 + 12 + const installUrl = 'https://github.com/apps/synchub-to/installations/new' 13 + </script> 14 + 1 15 <template> 2 - <div> 16 + <main> 3 17 <h1>synchub.to</h1> 4 18 <p>Mirror your GitHub repos to tangled.org, automatically.</p> 5 - </div> 19 + 20 + <section class="signin"> 21 + <h2>Sign in</h2> 22 + <p class="muted"> 23 + Already installed the GitHub App and connected your tangled 24 + identity? Enter your handle to sign in on this device. 25 + </p> 26 + <form action="/api/atproto/login" method="get"> 27 + <label> 28 + Handle 29 + <input 30 + type="text" 31 + name="handle" 32 + required 33 + placeholder="alice.bsky.social" 34 + autocomplete="username" 35 + autocapitalize="none" 36 + autocorrect="off" 37 + spellcheck="false" 38 + > 39 + </label> 40 + <button type="submit">Sign in</button> 41 + </form> 42 + </section> 43 + 44 + <section class="install"> 45 + <h2>New here?</h2> 46 + <p class="muted"> 47 + First install the GitHub App on the repos you'd like to mirror, 48 + then come back and sign in with your tangled handle. 49 + </p> 50 + <a class="button" :href="installUrl"> 51 + Install the GitHub App 52 + </a> 53 + </section> 54 + </main> 6 55 </template> 56 + 57 + <style scoped> 58 + main { 59 + max-width: 36rem; 60 + margin: 4rem auto; 61 + padding: 0 1.5rem; 62 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; 63 + line-height: 1.5; 64 + } 65 + h1 { 66 + font-size: 2rem; 67 + margin-bottom: 0.25rem; 68 + } 69 + h2 { 70 + font-size: 1.1rem; 71 + margin-bottom: 0.5rem; 72 + } 73 + .muted { 74 + color: #555; 75 + margin-bottom: 0.75rem; 76 + } 77 + section { 78 + margin-top: 2.5rem; 79 + padding-top: 1.5rem; 80 + border-top: 1px solid #eee; 81 + } 82 + form { 83 + display: flex; 84 + flex-direction: column; 85 + gap: 0.5rem; 86 + } 87 + label { 88 + display: flex; 89 + flex-direction: column; 90 + gap: 0.25rem; 91 + font-size: 0.875rem; 92 + } 93 + input { 94 + padding: 0.5rem 0.75rem; 95 + border: 1px solid #ccc; 96 + border-radius: 4px; 97 + font-size: 1rem; 98 + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; 99 + } 100 + button, .button { 101 + display: inline-block; 102 + padding: 0.5rem 1rem; 103 + background: #111; 104 + color: #fff; 105 + border: 0; 106 + border-radius: 4px; 107 + font-size: 1rem; 108 + cursor: pointer; 109 + text-decoration: none; 110 + text-align: center; 111 + width: fit-content; 112 + } 113 + button:hover, .button:hover { 114 + background: #333; 115 + } 116 + </style>
+64 -31
server/api/atproto/callback.get.ts
··· 1 + import { eq } from 'drizzle-orm' 1 2 import { userIdentity } from '~~/server/db/schema' 2 3 import { enqueue } from '~~/server/utils/queue' 3 4 import { writeSession } from '~~/server/utils/server-session' ··· 10 11 const client = await useOAuthClient() 11 12 const { session, state } = await client.callback(params) 12 13 13 - const installationId = state ? Number(state) : NaN 14 - if (!Number.isFinite(installationId)) { 15 - throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' }) 16 - } 14 + const db = useDb() 17 15 18 - const db = useDb() 19 - await db.insert(userIdentity).values({ 20 - did: session.did, 21 - handle: null, // resolved separately; we don't have it from the session blob 22 - installationId, 23 - updatedAt: new Date(), 24 - }).onConflictDoUpdate({ 25 - target: userIdentity.did, 26 - set: { installationId, updatedAt: new Date() }, 27 - }) 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 + await db.insert(userIdentity).values({ 41 + did: session.did, 42 + handle: null, 43 + installationId, 44 + updatedAt: new Date(), 45 + }).onConflictDoUpdate({ 46 + target: userIdentity.did, 47 + set: { installationId, updatedAt: new Date() }, 48 + }) 49 + 50 + // Generate and publish the SSH key inline: it's one ed25519 keygen + one 51 + // PDS write, well under the function timeout, and lets us land users on 52 + // the dashboard already enrolled. Rotation is a separate dashboard 53 + // action that goes via the queue. 54 + await generateAndPublishKey({ oauthSession: session, installationId }) 28 55 29 - // Generate and publish the SSH key inline: it's one ed25519 keygen + one 30 - // PDS write, well under the function timeout, and lets us land users on the 31 - // dashboard already enrolled. Rotation is a separate dashboard action that 32 - // goes via the queue. 33 - await generateAndPublishKey({ 34 - oauthSession: session, 35 - installationId, 36 - }) 56 + // Backfill: enqueue a single job that walks the installation's repo list 57 + // and fans out per-repo enrolment. Doing this in the worker (rather than 58 + // inline here) keeps the OAuth callback fast regardless of repo count. 59 + await enqueue('tangled.backfill-installation', { installationId, page: 1 }) 60 + } 61 + else { 62 + // Returning sign-in. Look up the installation we previously bound. 63 + const rows = await db.select({ installationId: userIdentity.installationId }) 64 + .from(userIdentity) 65 + .where(eq(userIdentity.did, session.did)) 66 + .limit(1) 37 67 38 - // Backfill: enqueue a single job that walks the installation's repo list 39 - // and fans out per-repo enrolment. Doing this in the worker (rather than 40 - // inline here) keeps the OAuth callback fast regardless of repo count, and 41 - // gives us proper retry semantics if pagination doesn't finish in one 42 - // worker tick. 43 - await enqueue('tangled.backfill-installation', { installationId, page: 1 }) 68 + if (rows.length === 0 || rows[0]!.installationId === null) { 69 + // Authenticated tangled identity, but no GitHub install bound. Send 70 + // them to install the App. We deliberately do NOT write a session 71 + // yet — the dashboard needs an installation to be useful. 72 + const appInstallUrl = process.env.NUXT_GITHUB_APP_INSTALL_URL 73 + ?? 'https://github.com/apps/synchub-to/installations/new' 74 + await sendRedirect(event, appInstallUrl, 302) 75 + return 76 + } 77 + installationId = rows[0]!.installationId 78 + } 44 79 45 - // Sealed cookie session for the dashboard. Handle resolution is deferred: 46 - // for v1 we surface the DID and the GitHub install's `accountLogin`, which 47 - // we already have. A handle resolver can populate `session.handle` later. 80 + // Sealed cookie session for the dashboard. Handle resolution is deferred. 48 81 await writeSession(event, { did: session.did, installationId }) 49 82 50 83 await sendRedirect(event, '/dashboard', 302)
+17 -8
server/api/atproto/login.get.ts
··· 6 6 if (typeof handleRaw !== 'string' || !handleRaw.trim()) { 7 7 throw createError({ statusCode: 400, statusMessage: 'handle is required' }) 8 8 } 9 - if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) { 10 - throw createError({ statusCode: 400, statusMessage: 'installationId is required' }) 9 + 10 + // `installationId` is *optional*: 11 + // - First-time install → connect flow passes it (from the GitHub App 12 + // install settings) so we can bind the resulting `user_identity` row. 13 + // - Returning user sign-in (e.g. on a new device) omits it; the callback 14 + // looks up the existing `user_identity` row by DID. 15 + let installationId: string | undefined 16 + if (typeof installationIdRaw === 'string' && installationIdRaw !== '') { 17 + if (!/^\d+$/.test(installationIdRaw)) { 18 + throw createError({ statusCode: 400, statusMessage: 'installationId must be numeric' }) 19 + } 20 + installationId = installationIdRaw 11 21 } 12 22 13 23 const handle = handleRaw.trim() 14 - const installationId = installationIdRaw 15 - 16 24 const client = await useOAuthClient() 17 25 18 - // Round-trip the installation id via OAuth `state`. The library wraps and 19 - // signs `state` itself (PKCE + state CSRF protection are handled internally), 20 - // so this is safe to use as an opaque link key. 26 + // Round-trip the installation id via OAuth `state` when we have one. The 27 + // library wraps and signs `state` itself (PKCE + state CSRF protection are 28 + // handled internally), so this is safe to use as an opaque link key. When 29 + // absent, the callback resolves the installation via the returned DID. 21 30 const url = await client.authorize(handle, { 22 - state: installationId, 31 + ...(installationId ? { state: installationId } : {}), 23 32 scope: SYNCHUB_OAUTH_SCOPE, 24 33 }) 25 34