mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

fix: improvements

+487 -92
+226 -48
app/pages/dashboard.vue
··· 1 1 <script setup lang="ts"> 2 2 import { ref } from 'vue' 3 + import type { AccountsPayload } from '~~/server/api/me/accounts.get' 3 4 import type { DashboardPayload, DashboardRepo } from '~~/server/api/me/dashboard.get' 5 + 6 + const installUrl = 'https://github.com/apps/synchub-to/installations/new' 4 7 5 8 definePageMeta({ 6 9 middleware: ['authenticated'], ··· 12 15 }) 13 16 14 17 const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard') 18 + const { data: accountsData, refresh: refreshAccounts } = await useFetch<AccountsPayload>('/api/me/accounts') 19 + 20 + const switcher = useTemplateRef<HTMLDetailsElement>('switcher') 21 + const switcherOpen = ref(false) 22 + 23 + onMounted(() => { 24 + const onPointerDown = (e: PointerEvent) => { 25 + if (switcherOpen.value && switcher.value && !switcher.value.contains(e.target as Node)) { 26 + switcherOpen.value = false 27 + } 28 + } 29 + document.addEventListener('pointerdown', onPointerDown) 30 + onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown)) 31 + }) 32 + 33 + const activeAccount = computed(() => 34 + accountsData.value?.accounts.find(a => a.active) ?? null, 35 + ) 36 + const otherAccounts = computed(() => 37 + accountsData.value?.accounts.filter(a => !a.active) ?? [], 38 + ) 39 + 40 + function accountLabel(account: { handle: string | null, accountLogin: string | null, did: string }) { 41 + if (account.handle) return `@${account.handle}` 42 + if (account.accountLogin) return account.accountLogin 43 + return account.did 44 + } 45 + 46 + async function switchTo(did: string) { 47 + switcherOpen.value = false 48 + pendingAction.value = `switch:${did}` 49 + try { 50 + await postAction('/api/me/switch', { did }) 51 + await Promise.all([refresh(), refreshAccounts()]) 52 + flash.value = null 53 + } 54 + catch { 55 + flash.value = 'Error: could not switch account.' 56 + } 57 + finally { 58 + pendingAction.value = null 59 + } 60 + } 15 61 16 62 const flash = ref<string | null>(null) 17 63 const flashIsError = computed(() => flash.value?.startsWith('Error:') ?? false) ··· 50 96 // `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting 51 97 // a no-body request through native `fetch` and parsing the JSON manually 52 98 // sidesteps that. Same-origin, so cookies are sent automatically. 53 - async function postAction(url: string): Promise<unknown> { 54 - const response = await fetch(url, { method: 'POST', credentials: 'same-origin' }) 99 + async function postAction(url: string, payload?: unknown): Promise<unknown> { 100 + const response = await fetch(url, { 101 + method: 'POST', 102 + credentials: 'same-origin', 103 + ...(payload === undefined 104 + ? {} 105 + : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }), 106 + }) 55 107 if (!response.ok) { 56 108 let statusMessage = response.statusText 57 109 try { ··· 72 124 return runAction( 73 125 actionKey('resync', repo.id), 74 126 () => postAction(`/api/repos/${repo.id}/resync`), 75 - `Queued resync for ${repo.githubFullName}.`, 127 + `queued resync for ${repo.githubFullName}.`, 76 128 ) 77 129 } 78 130 ··· 80 132 return runAction( 81 133 actionKey('disable', repo.id), 82 134 () => postAction(`/api/repos/${repo.id}/disable`), 83 - `Disabled sync for ${repo.githubFullName}.`, 135 + `disabled sync for ${repo.githubFullName}.`, 84 136 ) 85 137 } 86 138 ··· 88 140 return runAction( 89 141 actionKey('enable', repo.id), 90 142 () => postAction(`/api/repos/${repo.id}/enable`), 91 - `Re-enabled sync for ${repo.githubFullName}.`, 143 + `re-enabled sync for ${repo.githubFullName}.`, 92 144 ) 93 145 } 94 146 ··· 96 148 return runAction( 97 149 'rotate-key', 98 150 () => postAction('/api/me/rotate-key'), 99 - 'Queued SSH key rotation.', 151 + 'queued SSH key rotation.', 100 152 ) 101 153 } 102 154 103 155 async function logout() { 156 + switcherOpen.value = false 104 157 pendingAction.value = 'logout' 105 158 try { 106 - await postAction('/api/me/logout') 107 - await navigateTo('/') 159 + const result = await postAction('/api/me/logout') as { remaining: number } 160 + if (result.remaining > 0) { 161 + await Promise.all([refresh(), refreshAccounts()]) 162 + flash.value = null 163 + pendingAction.value = null 164 + } 165 + else { 166 + await navigateTo('/') 167 + } 108 168 } 109 169 catch { 110 170 pendingAction.value = null 111 - flash.value = 'Error: could not sign out.' 171 + flash.value = 'error: could not sign out.' 112 172 } 113 173 } 114 174 ··· 137 197 <div class="page"> 138 198 <a class="skip-link" href="#main">skip to main content</a> 139 199 <header class="nav-term"> 140 - <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/dashboard" aria-label="synchub.to dashboard"><SynchubMark :wordmark="true" :size="18" /></a> <button type="button" class="nav-term__logout" :disabled="pendingAction !== null" :aria-busy="pendingAction === 'logout'" @click="logout">{{ pendingAction === 'logout' ? 'logging out…' : '--logout' }}</button></nav> 200 + <nav class="nav-term__line" aria-label="primary"> 201 + <span class="prompt" aria-hidden="true">&gt;</span> 202 + <a class="nav-term__mark" href="/dashboard"><SynchubMark :wordmark="true" :size="18" /></a> 203 + <details ref="switcher" class="switcher" :open="switcherOpen" @toggle="switcherOpen = ($event.target as HTMLDetailsElement).open"> 204 + <summary class="switcher__summary"> 205 + <span class="switcher__active">{{ activeAccount ? accountLabel(activeAccount) : 'account' }}</span> 206 + <span class="switcher__caret" aria-hidden="true">▾</span> 207 + </summary> 208 + <div class="switcher__menu"> 209 + <p v-if="activeAccount" class="switcher__current"> 210 + <span class="switcher__handle">{{ accountLabel(activeAccount) }}</span> 211 + <span v-if="activeAccount.accountLogin" class="switcher__org">{{ activeAccount.accountLogin }}</span> 212 + </p> 213 + <template v-if="otherAccounts.length"> 214 + <p class="switcher__label">switch to</p> 215 + <button 216 + v-for="account in otherAccounts" 217 + :key="account.did" 218 + type="button" 219 + class="switcher__item" 220 + :disabled="pendingAction !== null" 221 + @click="switchTo(account.did)" 222 + > 223 + <span class="switcher__handle">{{ accountLabel(account) }}</span> 224 + <span v-if="account.accountLogin" class="switcher__org">{{ account.accountLogin }}</span> 225 + </button> 226 + </template> 227 + <a class="switcher__item switcher__add" :href="installUrl">+ add account</a> 228 + <button 229 + type="button" 230 + class="switcher__item switcher__logout" 231 + :disabled="pendingAction !== null" 232 + :aria-busy="pendingAction === 'logout'" 233 + @click="logout" 234 + > 235 + {{ pendingAction === 'logout' ? 'logging out…' : 'log out' }} 236 + </button> 237 + </div> 238 + </details> 239 + </nav> 141 240 </header> 142 241 143 242 <div id="main" class="intro"> ··· 155 254 <section class="card"> 156 255 <h2>identity</h2> 157 256 <dl> 158 - <dt>tangled DID</dt> 159 - <dd><code>{{ data.did }}</code></dd> 160 - <template v-if="data.handle"> 161 - <dt>handle</dt> 162 - <dd>@{{ data.handle }}</dd> 163 - </template> 257 + <dt>tangled</dt> 258 + <dd> 259 + <span v-if="data.handle">@{{ data.handle }}</span> 260 + <code class="did" :class="{ 'did--secondary': data.handle }">{{ data.did }}</code> 261 + </dd> 164 262 <template v-if="data.installation"> 165 - <dt>GitHub installation</dt> 263 + <dt>GitHub</dt> 166 264 <dd> 167 265 {{ data.installation.accountLogin }} 168 - <span class="muted">({{ data.installation.accountType }}, install #{{ data.installation.id }})</span> 266 + <span class="muted">({{ data.installation.accountType.toLowerCase() }}, install #{{ data.installation.id }})</span> 169 267 <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span> 170 268 </dd> 171 269 </template> 172 270 <template v-else> 173 - <dt>GitHub installation</dt> 271 + <dt>GitHub</dt> 174 272 <dd class="muted"> 175 273 no matching install row 176 274 </dd> 177 275 </template> 178 276 </dl> 179 - <p class="muted small"> 180 - connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 181 - <a href="https://github.com/apps/synchub-to/installations/new">connect a different installation?</a> 182 - </p> 183 277 </section> 184 278 185 279 <section class="card"> ··· 199 293 :aria-busy="pendingAction === 'rotate-key'" 200 294 @click="rotateKey" 201 295 > 202 - {{ pendingAction === 'rotate-key' ? 'Rotating…' : 'Rotate SSH key' }} 296 + {{ pendingAction === 'rotate-key' ? 'rotating…' : 'rotate SSH key' }} 203 297 </button> 204 298 </section> 205 299 ··· 213 307 <div class="repo__main"> 214 308 <div class="repo__head"> 215 309 <a class="repo__name" :href="`https://github.com/${repo.githubFullName}`" rel="noopener noreferrer">{{ repo.githubFullName }}</a> 216 - <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 217 310 <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 311 + <span v-else class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 218 312 </div> 219 313 <dl class="repo__meta"> 220 314 <div class="repo__meta-item"> ··· 242 336 :aria-busy="pendingAction === actionKey('resync', repo.id)" 243 337 @click="resync(repo)" 244 338 > 245 - {{ pendingAction === actionKey('resync', repo.id) ? 'Resyncing…' : 'Resync' }} 339 + {{ pendingAction === actionKey('resync', repo.id) ? 'resyncing…' : 'resync' }} 246 340 </button> 247 341 <button 248 342 v-if="repo.disabledAt" ··· 251 345 :aria-busy="pendingAction === actionKey('enable', repo.id)" 252 346 @click="enable(repo)" 253 347 > 254 - {{ pendingAction === actionKey('enable', repo.id) ? 'Enabling…' : 'Enable' }} 348 + {{ pendingAction === actionKey('enable', repo.id) ? 'enabling…' : 'enable' }} 255 349 </button> 256 350 <button 257 351 v-else ··· 260 354 :aria-busy="pendingAction === actionKey('disable', repo.id)" 261 355 @click="disable(repo)" 262 356 > 263 - {{ pendingAction === actionKey('disable', repo.id) ? 'Disabling…' : 'Disable' }} 357 + {{ pendingAction === actionKey('disable', repo.id) ? 'disabling…' : 'disable' }} 264 358 </button> 265 359 </div> 266 360 </li> ··· 310 404 margin-right: 1.2ch; 311 405 } 312 406 313 - .nav-term__args { 314 - display: inline-flex; 315 - align-items: center; 316 - gap: 0.9ch; 317 - list-style: none; 318 - margin: 0; 319 - padding: 0; 407 + .switcher { 408 + margin-left: auto; 409 + position: relative; 320 410 } 321 411 322 - .nav-term__args a { 412 + .switcher__summary { 323 413 display: inline-flex; 324 414 align-items: center; 415 + gap: 0.5ch; 325 416 min-height: 44px; 417 + padding: 0.2rem 0.7rem; 418 + border: var(--rule-hair) solid var(--color-rule-interactive); 419 + border-radius: var(--radius-sm); 326 420 color: var(--color-muted); 327 - text-decoration: underline; 328 - text-underline-offset: 2px; 421 + font-family: var(--font-mono); 422 + font-size: var(--text-sm); 329 423 white-space: nowrap; 424 + cursor: pointer; 425 + list-style: none; 426 + transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); 330 427 } 331 428 332 - .nav-term__args a:hover { color: var(--color-ink); } 429 + .switcher__summary::-webkit-details-marker { display: none; } 430 + .switcher__summary:hover { border-color: var(--color-accent); color: var(--color-ink); } 431 + .switcher[open] .switcher__summary { border-color: var(--color-accent); color: var(--color-ink); } 432 + .switcher[open] .switcher__caret { transform: rotate(180deg); } 433 + 434 + .switcher__caret { 435 + display: inline-block; 436 + color: var(--color-neutral); 437 + transition: transform var(--dur-micro) var(--ease-out); 438 + } 439 + 440 + .switcher__menu { 441 + position: absolute; 442 + right: 0; 443 + top: calc(100% + var(--space-2xs)); 444 + z-index: var(--z-overlay); 445 + min-width: 13rem; 446 + display: flex; 447 + flex-direction: column; 448 + gap: 1px; 449 + padding: var(--space-2xs); 450 + background: var(--color-paper-3); 451 + border: var(--rule-hair) solid var(--color-rule); 452 + border-radius: var(--radius-md); 453 + } 454 + 455 + .switcher__current { 456 + display: flex; 457 + flex-direction: column; 458 + gap: 1px; 459 + margin: 0; 460 + padding: var(--space-xs) var(--space-sm); 461 + } 462 + 463 + .switcher__label { 464 + margin: var(--space-2xs) 0 0; 465 + padding: 0 var(--space-sm); 466 + font-size: var(--text-xs); 467 + letter-spacing: 0.06em; 468 + text-transform: uppercase; 469 + color: var(--color-neutral); 470 + } 333 471 334 - .nav-term__logout { 335 - margin-left: auto; 336 - padding: 0.2rem 0.7rem; 337 - border: var(--rule-hair) solid var(--color-rule-interactive); 472 + .switcher__handle { 473 + font-family: var(--font-mono); 474 + color: var(--color-ink); 475 + } 476 + 477 + .switcher__org { 478 + font-size: var(--text-xs); 479 + color: var(--color-neutral); 480 + } 481 + 482 + .switcher__item { 483 + display: flex; 484 + flex-direction: column; 485 + gap: 1px; 486 + align-items: start; 487 + width: 100%; 488 + min-height: 44px; 489 + justify-content: center; 490 + padding: var(--space-xs) var(--space-sm); 491 + border: 0; 338 492 border-radius: var(--radius-sm); 339 493 background: transparent; 340 494 color: var(--color-muted); 341 - font-family: var(--font-mono); 495 + font: inherit; 342 496 font-size: var(--text-sm); 497 + text-align: left; 498 + text-decoration: none; 343 499 white-space: nowrap; 344 500 cursor: pointer; 345 - transition: border-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); 501 + transition: background-color var(--dur-micro) var(--ease-out); 346 502 } 347 503 348 - .nav-term__logout:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-ink); } 349 - .nav-term__logout:disabled { opacity: 0.5; cursor: progress; } 504 + .switcher__item:hover:not(:disabled) { background: var(--color-paper-2); } 505 + .switcher__item:disabled { opacity: 0.5; cursor: progress; } 506 + 507 + .switcher__add { 508 + border-top: var(--rule-hair) solid var(--color-rule); 509 + border-radius: 0; 510 + margin-top: var(--space-2xs); 511 + color: var(--color-accent-dim); 512 + } 513 + 514 + .switcher__logout { color: var(--color-muted); } 515 + .switcher__logout:hover:not(:disabled) { color: var(--color-error); } 350 516 351 517 .caret { 352 518 display: inline-block; ··· 393 559 dl { 394 560 display: grid; 395 561 grid-template-columns: max-content minmax(0, 1fr); 562 + align-items: baseline; 396 563 gap: var(--space-2xs) var(--space-md); 397 - margin: 0 0 var(--space-sm); 564 + margin: 0; 565 + } 566 + 567 + .did { 568 + word-break: break-all; 569 + } 570 + 571 + .did--secondary { 572 + display: block; 573 + margin-top: 2px; 574 + font-size: var(--text-xs); 575 + color: var(--color-neutral); 398 576 } 399 577 400 578 dt {
+15 -8
app/pages/index.vue
··· 22 22 <div class="shell"> 23 23 <a class="skip-link" href="#main">skip to main content</a> 24 24 <header class="nav-term"> 25 - <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/" aria-label="synchub.to home"><SynchubMark :wordmark="true" :size="18" /></a> <ul class="nav-term__args"><li><a href="#start">--start</a></li> <li><a href="#signin">--sign-in</a></li></ul><span class="caret" aria-hidden="true">█</span></nav> 25 + <nav class="nav-term__line" aria-label="primary"><span class="prompt" aria-hidden="true">&gt;</span> <a class="nav-term__mark" href="/"><SynchubMark :wordmark="true" :size="18" /></a> <ul class="nav-term__args"><li><a href="#start">--start</a></li> <li><a href="#signin">--sign-in</a></li></ul><span class="caret" aria-hidden="true"></span></nav> 26 26 </header> 27 27 28 28 <main id="main"> ··· 35 35 and tags will stay in sync. 36 36 </p> 37 37 38 - <pre class="session" aria-label="How synchub works"><span class="c"># 1 · install the github app on the repos to mirror</span> 38 + <pre class="session" role="img" aria-label="How synchub works"><span class="c"># 1 · install the github app on the repos to mirror</span> 39 39 <span class="p">$</span> gh app install <span class="a">synchub-to</span> 40 40 41 41 <span class="c"># 2 · connect your tangled identity</span> ··· 43 43 44 44 <span class="c"># 3 · push as usual</span> 45 45 <span class="p">$</span> git push origin main 46 - <span class="ok">✓</span> mirrored alice/widget → tangled.org <span class="dim">(2 refs)</span><span class="caret" aria-hidden="true">█</span></pre> 46 + <span class="ok">✓</span> mirrored alice/widget → tangled.org <span class="dim">(2 refs)</span><span class="caret" aria-hidden="true"></span></pre> 47 47 </section> 48 48 49 49 <section id="start" class="panel reveal" style="--i: 1"> ··· 149 149 150 150 .caret { 151 151 display: inline-block; 152 - width: 1ch; 153 - color: var(--color-accent); 154 - animation: blink 1.05s steps(2) infinite; 152 + width: 0.55ch; 153 + height: 1em; 154 + margin-left: 0.15ch; 155 + vertical-align: -0.12em; 156 + background: var(--color-accent); 157 + animation: blink 1.1s var(--ease-in-out) infinite; 155 158 } 156 159 157 - @keyframes blink { 50% { opacity: 0; } } 158 - @media (prefers-reduced-motion: reduce) { .caret { animation: none; } } 160 + @keyframes blink { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } } 161 + @media (prefers-reduced-motion: reduce) { .caret { animation: none; opacity: 1; } } 159 162 160 163 .hero { 161 164 padding-block: var(--space-2xl) var(--space-xl); ··· 252 255 padding-inline: 0.75ch 0; 253 256 color: var(--color-accent); 254 257 font-family: var(--font-mono); 258 + } 259 + 260 + .signin input:focus-visible { 261 + outline: none; 255 262 } 256 263 257 264 .signin input {
+1
nuxt.config.ts
··· 42 42 app: { 43 43 head: { 44 44 htmlAttrs: { lang: 'en' }, 45 + title: 'synchub.to', 45 46 link: [ 46 47 { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, 47 48 { rel: 'alternate icon', href: '/favicon.ico' },
public/favicon.ico

This is a binary file and will not be displayed.

+23 -6
server/api/atproto/callback.get.ts
··· 1 1 import { and, eq, ne } from 'drizzle-orm' 2 2 import { userIdentity } from '~~/server/db/schema' 3 3 import { enqueue } from '~~/server/utils/queue' 4 - import { writeSession } from '~~/server/utils/server-session' 4 + import { resolveHandle } from '~~/server/utils/resolve-handle' 5 + import { addAccount } from '~~/server/utils/server-session' 5 6 import { generateAndPublishKey, revokeKeyForInstallationDid } from '~~/server/utils/tangled-pubkey' 6 7 7 8 export default defineEventHandler(async event => { ··· 29 30 // would just show a useless page. 30 31 31 32 let installationId: number | undefined 33 + let handle: string | null = null 32 34 33 35 if (state) { 34 36 const parsed = Number(state) ··· 63 65 )) 64 66 } 65 67 68 + handle = await resolveHandle(session) 69 + 66 70 await db.insert(userIdentity).values({ 67 71 did: session.did, 68 - handle: null, 72 + handle, 69 73 installationId, 70 74 updatedAt: new Date(), 71 75 }).onConflictDoUpdate({ 72 76 target: userIdentity.did, 73 - set: { installationId, updatedAt: new Date() }, 77 + set: { installationId, handle, updatedAt: new Date() }, 74 78 }) 75 79 76 80 // Generate and publish the SSH key inline: it's one ed25519 keygen + one ··· 86 90 } 87 91 else { 88 92 // Returning sign-in. Look up the installation we previously bound. 89 - const rows = await db.select({ installationId: userIdentity.installationId }) 93 + const rows = await db.select({ 94 + installationId: userIdentity.installationId, 95 + handle: userIdentity.handle, 96 + }) 90 97 .from(userIdentity) 91 98 .where(eq(userIdentity.did, session.did)) 92 99 .limit(1) ··· 101 108 return 102 109 } 103 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 + } 104 120 } 105 121 106 - // Sealed cookie session for the dashboard. Handle resolution is deferred. 107 - await writeSession(event, { did: session.did, installationId }) 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 }) 108 125 109 126 await sendRedirect(event, '/dashboard', 302) 110 127 })
+53
server/api/me/accounts.get.ts
··· 1 + import { inArray } from 'drizzle-orm' 2 + import { installation, userIdentity } from '~~/server/db/schema' 3 + import { useDb } from '~~/server/utils/db' 4 + import { getDeviceSession, requireSession } from '~~/server/utils/server-session' 5 + 6 + export interface AccountSummary { 7 + did: string 8 + installationId: number 9 + handle: string | null 10 + accountLogin: string | null 11 + active: boolean 12 + } 13 + 14 + export interface AccountsPayload { 15 + active: string 16 + accounts: AccountSummary[] 17 + } 18 + 19 + export default defineEventHandler(async (event): Promise<AccountsPayload> => { 20 + await requireSession(event) 21 + const data = (await getDeviceSession(event))! 22 + 23 + const db = useDb() 24 + const dids = data.accounts.map(a => a.did) 25 + const installIds = data.accounts.map(a => a.installationId) 26 + 27 + const [identityRows, installRows] = await Promise.all([ 28 + dids.length > 0 29 + ? db.select({ did: userIdentity.did, handle: userIdentity.handle }) 30 + .from(userIdentity) 31 + .where(inArray(userIdentity.did, dids)) 32 + : Promise.resolve([]), 33 + installIds.length > 0 34 + ? db.select({ id: installation.id, accountLogin: installation.accountLogin }) 35 + .from(installation) 36 + .where(inArray(installation.id, installIds)) 37 + : Promise.resolve([]), 38 + ]) 39 + 40 + const handleByDid = new Map(identityRows.map(r => [r.did, r.handle])) 41 + const loginById = new Map(installRows.map(r => [r.id, r.accountLogin])) 42 + 43 + return { 44 + active: data.active, 45 + accounts: data.accounts.map(a => ({ 46 + did: a.did, 47 + installationId: a.installationId, 48 + handle: a.handle ?? handleByDid.get(a.did) ?? null, 49 + accountLogin: loginById.get(a.installationId) ?? null, 50 + active: a.did === data.active, 51 + })), 52 + } 53 + })
+3 -4
server/api/me/logout.post.ts
··· 1 - import { sessionConfig } from '~~/server/utils/server-session' 1 + import { dropActiveAccount } from '~~/server/utils/server-session' 2 2 3 3 export default defineEventHandler(async event => { 4 - const session = await useSession(event, sessionConfig()) 5 - await session.clear() 6 - return { ok: true } 4 + const remaining = await dropActiveAccount(event) 5 + return { ok: true, remaining } 7 6 })
+17
server/api/me/switch.post.ts
··· 1 + import { requireSession, switchAccount } from '~~/server/utils/server-session' 2 + 3 + export default defineEventHandler(async event => { 4 + await requireSession(event) 5 + const body = await readBody<{ did?: unknown }>(event) 6 + const did = typeof body?.did === 'string' ? body.did : null 7 + if (!did) { 8 + throw createError({ statusCode: 400, statusMessage: 'missing did' }) 9 + } 10 + 11 + const switched = await switchAccount(event, did) 12 + if (!switched) { 13 + throw createError({ statusCode: 404, statusMessage: 'account not signed in on this device' }) 14 + } 15 + 16 + return { ok: true, active: did } 17 + })
-2
server/utils/git-wire/receive-pack.ts
··· 206 206 stdin.on('close', resolve) 207 207 }) 208 208 } 209 - 210 - export { RemoteRejectedError, ZERO_SHA }
+1 -3
server/utils/git-wire/refs.ts
··· 1 1 import { lineToString } from './pkt-line' 2 2 3 - const ZERO_SHA = '0000000000000000000000000000000000000000' 3 + export const ZERO_SHA = '0000000000000000000000000000000000000000' 4 4 5 5 export interface Advertisement { 6 6 /** Ref name -> object SHA (unpeeled). For annotated tags this is the tag object. */ ··· 65 65 66 66 return { refs, peeled, capabilities } 67 67 } 68 - 69 - export { ZERO_SHA }
-2
server/utils/require-session.ts
··· 1 - export { getSessionData, requireSession, writeSession } from './server-session' 2 - export type { SynchubSessionData } from './server-session'
+24
server/utils/resolve-handle.ts
··· 1 + import { Agent } from '@atproto/api' 2 + import type { OAuthSession } from '@atproto/oauth-client-node' 3 + 4 + /** 5 + * Resolve the human-readable handle for the authenticated DID. 6 + * 7 + * `com.atproto.repo.describeRepo` returns the handle the PDS has verified 8 + * against the DID document, so it's the authoritative answer and needs no 9 + * appview access. Best-effort: a handle is a display convenience, so any 10 + * failure (network, unverified handle) returns null rather than blocking the 11 + * sign-in flow. Strips a leading `@` if the PDS includes one. 12 + */ 13 + export async function resolveHandle(oauthSession: OAuthSession): Promise<string | null> { 14 + try { 15 + const agent = new Agent(oauthSession) 16 + const { data } = await agent.com.atproto.repo.describeRepo({ repo: oauthSession.did }) 17 + const handle = data.handle?.trim().replace(/^@/, '') 18 + if (!handle || handle === 'handle.invalid') return null 19 + return handle 20 + } 21 + catch { 22 + return null 23 + } 24 + }
+88 -13
server/utils/server-session.ts
··· 10 10 * `process.env` directly so the helper is callable outside a request context 11 11 * for tests, mirroring `encryption.ts`. 12 12 * 13 - * v1 stores `installationId` alongside `did`: a user may have installed the 14 - * GitHub App on more than one account (personal + an org), but the session 15 - * pins us to the one they last authenticated against. See the dashboard 16 - * "Connect a different installation?" link for the workaround. 13 + * A user may have connected more than one tangled identity on this device 14 + * (e.g. a personal account and an org). The cookie therefore holds a list of 15 + * `accounts`, one of which is `active`. `requireSession()` flattens to the 16 + * active account so downstream handlers keep working with a single 17 + * `{ did, installationId, handle }`. Switching is a device-local convenience: 18 + * the OAuth callback appends, `/api/me/switch` flips `active`, logout drops the 19 + * active account. 17 20 */ 18 - export interface SynchubSessionData { 21 + export interface SynchubAccount { 19 22 did: string 20 23 installationId: number 21 24 handle?: string 25 + } 26 + 27 + export interface SynchubSessionData { 28 + active: string 29 + accounts: SynchubAccount[] 22 30 } 23 31 24 32 const COOKIE_NAME = 'synchub-session' ··· 47 55 } 48 56 } 49 57 50 - export async function getSessionData(event: H3Event): Promise<SynchubSessionData | null> { 58 + /** 59 + * Normalise raw cookie data into a valid session, or null. Drops malformed 60 + * account entries; if `active` doesn't name a surviving account, falls back to 61 + * the first. Exported for unit testing the normalisation in isolation. 62 + */ 63 + export function readAccounts(data: Partial<SynchubSessionData>): SynchubSessionData | null { 64 + if (typeof data.active !== 'string' || !Array.isArray(data.accounts)) return null 65 + const accounts = data.accounts.filter( 66 + (a): a is SynchubAccount => typeof a?.did === 'string' && typeof a?.installationId === 'number', 67 + ) 68 + if (accounts.length === 0) return null 69 + const active = accounts.some(a => a.did === data.active) ? data.active : accounts[0]!.did 70 + return { active, accounts } 71 + } 72 + 73 + /** 74 + * Return all accounts on this device plus the active DID, or null if the 75 + * cookie is empty / malformed. 76 + */ 77 + export async function getDeviceSession(event: H3Event): Promise<SynchubSessionData | null> { 51 78 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 52 - const { did, installationId } = session.data 53 - if (typeof did !== 'string' || typeof installationId !== 'number') return null 54 - return { did, installationId, handle: session.data.handle } 79 + return readAccounts(session.data) 80 + } 81 + 82 + /** 83 + * Resolve the active account, flattened to the single-account shape that 84 + * handlers expect. 85 + */ 86 + export async function getSessionData(event: H3Event): Promise<SynchubAccount | null> { 87 + const data = await getDeviceSession(event) 88 + if (!data) return null 89 + return data.accounts.find(a => a.did === data.active) ?? null 55 90 } 56 91 57 92 /** 58 - * Return the current session or throw a 401. Use from any handler that needs 93 + * Return the active account or throw a 401. Use from any handler that needs 59 94 * an authenticated user. The thrown error is consumed by Nitro and rendered 60 95 * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`. 61 96 */ 62 - export async function requireSession(event: H3Event): Promise<SynchubSessionData> { 97 + export async function requireSession(event: H3Event): Promise<SynchubAccount> { 63 98 const data = await getSessionData(event) 64 99 if (!data) { 65 100 throw createError({ statusCode: 401, statusMessage: 'unauthenticated' }) ··· 67 102 return data 68 103 } 69 104 70 - export async function writeSession(event: H3Event, data: SynchubSessionData): Promise<void> { 105 + /** 106 + * Add (or refresh) an account on this device and make it active. Dedupes by 107 + * DID: re-connecting an existing handle updates its installation binding and 108 + * activates it rather than duplicating the entry. 109 + */ 110 + export async function addAccount(event: H3Event, account: SynchubAccount): Promise<void> { 111 + const session = await useSession<SynchubSessionData>(event, sessionConfig()) 112 + const existing = readAccounts(session.data)?.accounts ?? [] 113 + const accounts = [...existing.filter(a => a.did !== account.did), account] 114 + await session.update({ active: account.did, accounts }) 115 + } 116 + 117 + /** 118 + * Switch the active account to `did`. Returns false if the DID isn't present 119 + * on this device. 120 + */ 121 + export async function switchAccount(event: H3Event, did: string): Promise<boolean> { 71 122 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 72 - await session.update(data) 123 + const data = readAccounts(session.data) 124 + if (!data || !data.accounts.some(a => a.did === did)) return false 125 + await session.update({ active: did, accounts: data.accounts }) 126 + return true 127 + } 128 + 129 + /** 130 + * Drop the active account. If others remain, the first becomes active and the 131 + * cookie is rewritten; otherwise the cookie is cleared. Returns the number of 132 + * accounts left signed in. 133 + */ 134 + export async function dropActiveAccount(event: H3Event): Promise<number> { 135 + const session = await useSession<SynchubSessionData>(event, sessionConfig()) 136 + const data = readAccounts(session.data) 137 + if (!data) { 138 + await session.clear() 139 + return 0 140 + } 141 + const remaining = data.accounts.filter(a => a.did !== data.active) 142 + if (remaining.length === 0) { 143 + await session.clear() 144 + return 0 145 + } 146 + await session.update({ active: remaining[0]!.did, accounts: remaining }) 147 + return remaining.length 73 148 }
-2
server/utils/splice.ts
··· 148 148 await session.push([{ ref, old, next: ZERO_SHA }], null) 149 149 return { status: 'synced' } 150 150 } 151 - 152 - export { fetchAdvertisement }
-2
server/utils/sync-push.ts
··· 108 108 .set({ status: 'error', lastError: message, updatedAt: new Date() }) 109 109 .where(eq(repoMapping.id, mappingId)) 110 110 } 111 - 112 - export { RemoteRejectedError }
+1 -1
server/utils/sync-ref.ts
··· 3 3 import { useDb } from './db' 4 4 import { RemoteRejectedError, WireError } from './git-wire/errors' 5 5 import { installationOctokit } from './github-app' 6 - import { fetchAdvertisement, spliceDelete, splicePush } from './splice' 6 + import { spliceDelete, splicePush } from './splice' 7 7 8 8 export type RefType = 'branch' | 'tag' 9 9
+35 -1
test/unit/server-session.spec.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, it } from 'vitest' 2 - import { sessionConfig } from '../../server/utils/server-session' 2 + import { readAccounts, sessionConfig } from '../../server/utils/server-session' 3 3 4 4 const ORIGINAL_PASSWORD = process.env.NUXT_SESSION_PASSWORD 5 5 const ORIGINAL_PUBLIC_URL = process.env.NUXT_PUBLIC_URL ··· 47 47 expect(config.cookie).toMatchObject({ secure: true }) 48 48 }) 49 49 }) 50 + 51 + describe('server-session: readAccounts', () => { 52 + const acct = (did: string, installationId: number) => ({ did, installationId }) 53 + 54 + it('returns null for empty or malformed cookie data', () => { 55 + expect(readAccounts({})).toBeNull() 56 + expect(readAccounts({ active: 'did:plc:1', accounts: [] })).toBeNull() 57 + expect(readAccounts({ active: 'did:plc:1' })).toBeNull() 58 + // eslint-disable-next-line ts/no-unsafe-type-assertion 59 + expect(readAccounts({ active: 'did:plc:1', accounts: 'nope' as unknown as [] })).toBeNull() 60 + }) 61 + 62 + it('keeps the active did when it names a present account', () => { 63 + const result = readAccounts({ active: 'did:plc:2', accounts: [acct('did:plc:1', 1), acct('did:plc:2', 2)] }) 64 + expect(result).toEqual({ active: 'did:plc:2', accounts: [acct('did:plc:1', 1), acct('did:plc:2', 2)] }) 65 + }) 66 + 67 + it('falls back active to the first account when active is stale', () => { 68 + const result = readAccounts({ active: 'did:plc:gone', accounts: [acct('did:plc:1', 1), acct('did:plc:2', 2)] }) 69 + expect(result?.active).toBe('did:plc:1') 70 + }) 71 + 72 + it('drops malformed account entries', () => { 73 + const accounts = [ 74 + acct('did:plc:1', 1), 75 + // eslint-disable-next-line ts/no-unsafe-type-assertion 76 + { did: 'did:plc:2' } as unknown as { did: string, installationId: number }, 77 + // eslint-disable-next-line ts/no-unsafe-type-assertion 78 + { installationId: 3 } as unknown as { did: string, installationId: number }, 79 + ] 80 + const result = readAccounts({ active: 'did:plc:1', accounts }) 81 + expect(result?.accounts).toEqual([acct('did:plc:1', 1)]) 82 + }) 83 + })