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 dashboard with repo status and manual resync

+1077 -20
+8
.env.example
··· 39 39 NUXT_ENCRYPTION_KEY=<base64-encoded 32 bytes> 40 40 41 41 # --------------------------------------------------------------------------- 42 + # Dashboard session password. Used by h3's `useSession` to seal the 43 + # `synchub-session` cookie. 32+ characters of entropy. 44 + # Generate with: pnpm gen:encryption-key (any sufficiently long random string 45 + # works; the base64 output of 32 random bytes is convenient). 46 + # --------------------------------------------------------------------------- 47 + NUXT_SESSION_PASSWORD=<32+ char random string> 48 + 49 + # --------------------------------------------------------------------------- 42 50 # GitHub App credentials. After creating the App at 43 51 # https://github.com/settings/apps/new, copy: 44 52 # - The numeric App ID (top of the App settings page).
+23
app/middleware/authenticated.ts
··· 1 + export default defineNuxtRouteMiddleware(async () => { 2 + // On SSR we must forward the request cookies so the whoami probe sees the 3 + // session. On the client, same-origin `$fetch` already sends cookies. 4 + const headers: Record<string, string> = {} 5 + if (import.meta.server) { 6 + const cookie = useRequestHeader('cookie') 7 + if (cookie) headers.cookie = cookie 8 + } 9 + 10 + try { 11 + await $fetch('/api/me/whoami', { headers }) 12 + } 13 + catch (err: unknown) { 14 + const status = err && typeof err === 'object' 15 + ? (('statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : undefined) 16 + ?? ('status' in err && typeof err.status === 'number' ? err.status : undefined)) 17 + : undefined 18 + if (status === 401) { 19 + return navigateTo('/', { redirectCode: 302 }) 20 + } 21 + throw err 22 + } 23 + })
+403
app/pages/dashboard.vue
··· 1 + <script setup lang="ts"> 2 + import type { DashboardPayload, DashboardRepo } from '~~/server/api/me/dashboard.get' 3 + 4 + definePageMeta({ 5 + middleware: ['authenticated'], 6 + }) 7 + 8 + useSeoMeta({ 9 + title: 'Dashboard · synchub.to', 10 + description: 'Your synchub.to mirror status.', 11 + }) 12 + 13 + const { data, refresh, error } = await useFetch<DashboardPayload>('/api/me/dashboard') 14 + 15 + const flash = ref<string | null>(null) 16 + const pendingAction = ref<string | null>(null) 17 + 18 + function actionKey(scope: string, id: number | string) { 19 + return `${scope}:${id}` 20 + } 21 + 22 + async function runAction(key: string, fn: () => Promise<unknown>, successMessage: string) { 23 + pendingAction.value = key 24 + flash.value = null 25 + try { 26 + await fn() 27 + flash.value = successMessage 28 + await refresh() 29 + } 30 + catch (err: unknown) { 31 + const message = (err as { data?: { statusMessage?: string }, message?: string } | null)?.data?.statusMessage 32 + ?? (err as Error | null)?.message 33 + ?? 'something went wrong' 34 + flash.value = `Error: ${message}` 35 + } 36 + finally { 37 + pendingAction.value = null 38 + } 39 + } 40 + 41 + // Typed Nitro routes use `:id` placeholders; passing the dynamic path to 42 + // `$fetch` blows up TS' pattern matcher with "excessive stack depth". Posting 43 + // a no-body request through native `fetch` and parsing the JSON manually 44 + // sidesteps that. Same-origin, so cookies are sent automatically. 45 + async function postAction(url: string): Promise<unknown> { 46 + const response = await fetch(url, { method: 'POST', credentials: 'same-origin' }) 47 + if (!response.ok) { 48 + let statusMessage = response.statusText 49 + try { 50 + const body: unknown = await response.json() 51 + if (body && typeof body === 'object' && 'statusMessage' in body && typeof body.statusMessage === 'string') { 52 + statusMessage = body.statusMessage 53 + } 54 + } 55 + catch { 56 + // body wasn't json; keep statusText 57 + } 58 + throw Object.assign(new Error(statusMessage), { statusCode: response.status }) 59 + } 60 + return response.json() 61 + } 62 + 63 + function resync(repo: DashboardRepo) { 64 + return runAction( 65 + actionKey('resync', repo.id), 66 + () => postAction(`/api/repos/${repo.id}/resync`), 67 + `Queued resync for ${repo.githubFullName}.`, 68 + ) 69 + } 70 + 71 + function disable(repo: DashboardRepo) { 72 + return runAction( 73 + actionKey('disable', repo.id), 74 + () => postAction(`/api/repos/${repo.id}/disable`), 75 + `Disabled sync for ${repo.githubFullName}.`, 76 + ) 77 + } 78 + 79 + function enable(repo: DashboardRepo) { 80 + return runAction( 81 + actionKey('enable', repo.id), 82 + () => postAction(`/api/repos/${repo.id}/enable`), 83 + `Re-enabled sync for ${repo.githubFullName}.`, 84 + ) 85 + } 86 + 87 + function rotateKey() { 88 + return runAction( 89 + 'rotate-key', 90 + () => postAction('/api/me/rotate-key'), 91 + 'Queued SSH key rotation.', 92 + ) 93 + } 94 + 95 + function summariseRefs(refs: Record<string, string>): string { 96 + const entries = Object.entries(refs) 97 + if (entries.length === 0) return '—' 98 + if (entries.length === 1) { 99 + const [ref, sha] = entries[0]! 100 + return `${ref} @ ${sha.slice(0, 7)}` 101 + } 102 + return `${entries.length} refs` 103 + } 104 + 105 + function fmtDate(iso: string | null): string { 106 + if (!iso) return '—' 107 + try { 108 + return new Date(iso).toLocaleString() 109 + } 110 + catch { 111 + return iso 112 + } 113 + } 114 + </script> 115 + 116 + <template> 117 + <div class="page"> 118 + <header> 119 + <h1>synchub.to</h1> 120 + <p class="muted"> 121 + Mirror status for your connected GitHub installation. 122 + </p> 123 + </header> 124 + 125 + <div v-if="error" class="card error"> 126 + Failed to load dashboard: {{ error.message }} 127 + </div> 128 + 129 + <div v-else-if="data"> 130 + <section class="card"> 131 + <h2>Identity</h2> 132 + <dl> 133 + <dt>tangled DID</dt> 134 + <dd><code>{{ data.did }}</code></dd> 135 + <template v-if="data.handle"> 136 + <dt>handle</dt> 137 + <dd>@{{ data.handle }}</dd> 138 + </template> 139 + <template v-if="data.installation"> 140 + <dt>GitHub installation</dt> 141 + <dd> 142 + {{ data.installation.accountLogin }} 143 + <span class="muted">({{ data.installation.accountType }}, install #{{ data.installation.id }})</span> 144 + <span v-if="data.installation.suspendedAt" class="badge badge-error">suspended</span> 145 + </dd> 146 + </template> 147 + <template v-else> 148 + <dt>GitHub installation</dt> 149 + <dd class="muted"> 150 + no matching install row 151 + </dd> 152 + </template> 153 + </dl> 154 + <p class="muted small"> 155 + Connected to <strong>{{ data.installation?.accountLogin ?? 'this account' }}</strong>. 156 + <a href="https://github.com/apps/synchub-to/installations/new">Connect a different installation?</a> 157 + </p> 158 + </section> 159 + 160 + <section class="card"> 161 + <h2>SSH key</h2> 162 + <template v-if="data.sshKey"> 163 + <p class="small muted"> 164 + Created {{ fmtDate(data.sshKey.createdAt) }}<span v-if="data.sshKey.rotatedAt">, last rotated {{ fmtDate(data.sshKey.rotatedAt) }}</span>. 165 + </p> 166 + <pre class="pubkey">{{ data.sshKey.publicKey }}</pre> 167 + </template> 168 + <p v-else class="muted"> 169 + No key on file yet. It's created automatically on first sign-in. 170 + </p> 171 + <button 172 + type="button" 173 + :disabled="pendingAction !== null" 174 + @click="rotateKey" 175 + > 176 + {{ pendingAction === 'rotate-key' ? 'Rotating…' : 'Rotate SSH key' }} 177 + </button> 178 + </section> 179 + 180 + <section class="card"> 181 + <h2>Repositories ({{ data.repos.length }})</h2> 182 + <p v-if="data.repos.length === 0" class="muted"> 183 + No repositories enrolled yet. New installs are backfilled in the background; refresh in a minute. 184 + </p> 185 + <table v-else> 186 + <thead> 187 + <tr> 188 + <th>GitHub</th> 189 + <th>Tangled</th> 190 + <th>Status</th> 191 + <th>Last synced</th> 192 + <th>Refs</th> 193 + <th>Actions</th> 194 + </tr> 195 + </thead> 196 + <tbody> 197 + <tr v-for="repo in data.repos" :key="repo.id"> 198 + <td> 199 + <a :href="`https://github.com/${repo.githubFullName}`" rel="noopener">{{ repo.githubFullName }}</a> 200 + </td> 201 + <td> 202 + <span v-if="repo.tangledFullName" class="small">{{ repo.tangledFullName }}</span> 203 + <span v-else class="muted small">—</span> 204 + </td> 205 + <td> 206 + <span class="badge" :class="`badge-${repo.status}`">{{ repo.status }}</span> 207 + <span v-if="repo.disabledAt" class="badge badge-disabled">disabled</span> 208 + <div v-if="repo.lastError" class="muted small"> 209 + {{ repo.lastError }} 210 + </div> 211 + </td> 212 + <td class="small"> 213 + {{ fmtDate(repo.lastSyncedAt) }} 214 + </td> 215 + <td class="small"> 216 + {{ summariseRefs(repo.lastSyncedRefs) }} 217 + </td> 218 + <td> 219 + <div class="actions"> 220 + <button 221 + type="button" 222 + :disabled="pendingAction !== null" 223 + @click="resync(repo)" 224 + > 225 + {{ pendingAction === actionKey('resync', repo.id) ? '…' : 'Resync' }} 226 + </button> 227 + <button 228 + v-if="repo.disabledAt" 229 + type="button" 230 + :disabled="pendingAction !== null" 231 + @click="enable(repo)" 232 + > 233 + {{ pendingAction === actionKey('enable', repo.id) ? '…' : 'Enable' }} 234 + </button> 235 + <button 236 + v-else 237 + type="button" 238 + :disabled="pendingAction !== null" 239 + @click="disable(repo)" 240 + > 241 + {{ pendingAction === actionKey('disable', repo.id) ? '…' : 'Disable' }} 242 + </button> 243 + </div> 244 + </td> 245 + </tr> 246 + </tbody> 247 + </table> 248 + </section> 249 + 250 + <p v-if="flash" class="flash" role="status"> 251 + {{ flash }} 252 + </p> 253 + </div> 254 + </div> 255 + </template> 256 + 257 + <style scoped> 258 + :root { 259 + --bg: #fafafa; 260 + --fg: #1a1a1a; 261 + --muted: #6b6b6b; 262 + --border: #e2e2e2; 263 + --accent: #2563eb; 264 + --error: #b91c1c; 265 + --warn: #b45309; 266 + --ok: #15803d; 267 + } 268 + 269 + .page { 270 + max-width: 64rem; 271 + margin: 0 auto; 272 + padding: 2rem 1rem; 273 + font-family: system-ui, sans-serif; 274 + color: var(--fg); 275 + } 276 + 277 + h1 { 278 + margin: 0 0 0.25rem; 279 + } 280 + 281 + h2 { 282 + margin: 0 0 1rem; 283 + font-size: 1.125rem; 284 + } 285 + 286 + .muted { 287 + color: var(--muted); 288 + } 289 + 290 + .small { 291 + font-size: 0.875rem; 292 + } 293 + 294 + .card { 295 + background: #fff; 296 + border: 1px solid var(--border); 297 + border-radius: 0.5rem; 298 + padding: 1.25rem; 299 + margin: 1rem 0; 300 + } 301 + 302 + .error { 303 + border-color: var(--error); 304 + color: var(--error); 305 + } 306 + 307 + dl { 308 + display: grid; 309 + grid-template-columns: max-content 1fr; 310 + gap: 0.25rem 1rem; 311 + margin: 0 0 0.75rem; 312 + } 313 + 314 + dt { 315 + font-weight: 600; 316 + } 317 + 318 + dd { 319 + margin: 0; 320 + } 321 + 322 + code { 323 + font-family: ui-monospace, monospace; 324 + font-size: 0.875rem; 325 + } 326 + 327 + .pubkey { 328 + font-family: ui-monospace, monospace; 329 + font-size: 0.75rem; 330 + background: #f3f3f3; 331 + padding: 0.5rem; 332 + border-radius: 0.25rem; 333 + overflow-x: auto; 334 + white-space: pre-wrap; 335 + word-break: break-all; 336 + margin: 0.5rem 0 1rem; 337 + } 338 + 339 + table { 340 + width: 100%; 341 + border-collapse: collapse; 342 + font-size: 0.875rem; 343 + } 344 + 345 + th, td { 346 + text-align: left; 347 + padding: 0.5rem 0.5rem; 348 + border-bottom: 1px solid var(--border); 349 + vertical-align: top; 350 + } 351 + 352 + th { 353 + font-weight: 600; 354 + background: #f3f3f3; 355 + } 356 + 357 + .badge { 358 + display: inline-block; 359 + padding: 0.125rem 0.5rem; 360 + border-radius: 999px; 361 + font-size: 0.75rem; 362 + background: #eee; 363 + color: #333; 364 + margin-right: 0.25rem; 365 + } 366 + 367 + .badge-active { background: #dcfce7; color: var(--ok); } 368 + .badge-pending { background: #fef3c7; color: var(--warn); } 369 + .badge-enrolling { background: #fef3c7; color: var(--warn); } 370 + .badge-error { background: #fee2e2; color: var(--error); } 371 + .badge-disabled { background: #e0e7ff; color: var(--accent); } 372 + 373 + .actions { 374 + display: flex; 375 + gap: 0.25rem; 376 + flex-wrap: wrap; 377 + } 378 + 379 + button { 380 + font: inherit; 381 + padding: 0.25rem 0.75rem; 382 + border-radius: 0.25rem; 383 + border: 1px solid var(--border); 384 + background: #fff; 385 + cursor: pointer; 386 + } 387 + 388 + button:disabled { 389 + opacity: 0.5; 390 + cursor: progress; 391 + } 392 + 393 + .flash { 394 + position: fixed; 395 + right: 1rem; 396 + bottom: 1rem; 397 + padding: 0.5rem 0.75rem; 398 + background: #111; 399 + color: #fff; 400 + border-radius: 0.25rem; 401 + font-size: 0.875rem; 402 + } 403 + </style>
+1
nuxt.config.ts
··· 21 21 workerBudgetMs: '', 22 22 encryptionKey: '', 23 23 atprotoPrivateJwk: '', 24 + sessionPassword: '', 24 25 public: { 25 26 url: '', 26 27 },
+6
server/api/atproto/callback.get.ts
··· 1 1 import { userIdentity } from '~~/server/db/schema' 2 2 import { enqueue } from '~~/server/utils/queue' 3 + import { writeSession } from '~~/server/utils/server-session' 3 4 import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' 4 5 5 6 export default defineEventHandler(async event => { ··· 40 41 // gives us proper retry semantics if pagination doesn't finish in one 41 42 // worker tick. 42 43 await enqueue('tangled.backfill-installation', { installationId, page: 1 }) 44 + 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. 48 + await writeSession(event, { did: session.did, installationId }) 43 49 44 50 await sendRedirect(event, '/dashboard', 302) 45 51 })
+123
server/api/me/dashboard.get.ts
··· 1 + import { sql } from 'drizzle-orm' 2 + import { installation, repoMapping, sshKey, userIdentity } from '~~/server/db/schema' 3 + import { useDb } from '~~/server/utils/db' 4 + import { requireSession } from '~~/server/utils/server-session' 5 + 6 + export interface DashboardRepo { 7 + id: number 8 + githubRepoId: number 9 + githubFullName: string 10 + tangledRepoDid: string | null 11 + tangledFullName: string | null 12 + knot: string | null 13 + status: string 14 + lastError: string | null 15 + disabledAt: string | null 16 + lastSyncedRefs: Record<string, string> 17 + lastSyncedAt: string | null 18 + refCount: number 19 + } 20 + 21 + export interface DashboardPayload { 22 + did: string 23 + handle: string | null 24 + installation: { 25 + id: number 26 + accountLogin: string 27 + accountType: string 28 + suspendedAt: string | null 29 + } | null 30 + hasSshKey: boolean 31 + sshKey: { 32 + publicKey: string 33 + createdAt: string 34 + rotatedAt: string | null 35 + } | null 36 + repos: DashboardRepo[] 37 + } 38 + 39 + export default defineEventHandler(async (event): Promise<DashboardPayload> => { 40 + const session = await requireSession(event) 41 + const db = useDb() 42 + 43 + const [identityRow, installRows, repoRows, sshKeyRows] = await Promise.all([ 44 + db.select({ did: userIdentity.did, handle: userIdentity.handle }) 45 + .from(userIdentity) 46 + .where(sql`${userIdentity.did} = ${session.did}`) 47 + .limit(1), 48 + db.select({ 49 + id: installation.id, 50 + accountLogin: installation.accountLogin, 51 + accountType: installation.accountType, 52 + suspendedAt: installation.suspendedAt, 53 + }) 54 + .from(installation) 55 + .where(sql`${installation.id} = ${session.installationId}`) 56 + .limit(1), 57 + db.select().from(repoMapping) 58 + .where(sql`${repoMapping.installationId} = ${session.installationId}`) 59 + .orderBy(sql`${repoMapping.githubFullName}`), 60 + db.select({ 61 + publicKey: sshKey.publicKey, 62 + createdAt: sshKey.createdAt, 63 + rotatedAt: sshKey.rotatedAt, 64 + }) 65 + .from(sshKey) 66 + .where(sql`${sshKey.installationId} = ${session.installationId} AND ${sshKey.did} = ${session.did}`) 67 + .limit(1), 68 + ]) 69 + 70 + const installRow = installRows[0] ?? null 71 + const sshKeyRow = sshKeyRows[0] ?? null 72 + 73 + const repos: DashboardRepo[] = repoRows.map(row => { 74 + // Schema audit prefers no new column for "last push time": `updatedAt` 75 + // moves on any mapping mutation (status, errors, disable toggle), so it's 76 + // a noisy proxy for "last sync". When at least one ref has synced, 77 + // surface `updatedAt` as a best-effort timestamp; when refs is empty, we 78 + // have nothing meaningful to show. 79 + // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` 80 + const refs = (row.lastSyncedRefs ?? {}) as Record<string, string> 81 + const refKeys = Object.keys(refs) 82 + return { 83 + id: row.id, 84 + githubRepoId: row.githubRepoId, 85 + githubFullName: row.githubFullName, 86 + tangledRepoDid: row.tangledRepoDid, 87 + tangledFullName: row.tangledFullName, 88 + knot: row.knot, 89 + status: row.status, 90 + lastError: row.lastError, 91 + disabledAt: row.disabledAt?.toISOString() ?? null, 92 + lastSyncedRefs: refs, 93 + lastSyncedAt: refKeys.length > 0 && row.updatedAt ? row.updatedAt.toISOString() : null, 94 + refCount: refKeys.length, 95 + } 96 + }) 97 + 98 + const installationPayload: DashboardPayload['installation'] = installRow 99 + ? { 100 + id: installRow.id, 101 + accountLogin: installRow.accountLogin, 102 + accountType: installRow.accountType, 103 + suspendedAt: installRow.suspendedAt?.toISOString() ?? null, 104 + } 105 + : null 106 + 107 + const sshKeyPayload: DashboardPayload['sshKey'] = sshKeyRow 108 + ? { 109 + publicKey: sshKeyRow.publicKey, 110 + createdAt: sshKeyRow.createdAt.toISOString(), 111 + rotatedAt: sshKeyRow.rotatedAt?.toISOString() ?? null, 112 + } 113 + : null 114 + 115 + return { 116 + did: session.did, 117 + handle: identityRow[0]?.handle ?? session.handle ?? null, 118 + installation: installationPayload, 119 + hasSshKey: sshKeyPayload !== null, 120 + sshKey: sshKeyPayload, 121 + repos, 122 + } 123 + })
+21
server/api/me/rotate-key.post.ts
··· 1 + import { enqueue } from '~~/server/utils/queue' 2 + import { requireSession } from '~~/server/utils/server-session' 3 + 4 + /** 5 + * Enqueue an SSH key rotation for the current `(did, installationId)`. 6 + * 7 + * The actual rotation runs in the worker: delete the existing 8 + * `sh.tangled.publicKey` PDS record, drop the local row, generate a fresh 9 + * keypair, publish the new public half. Doing this via the queue (rather 10 + * than inline like the signup path) means a slow PDS doesn't tie up the 11 + * request and we get retry semantics for free. 12 + */ 13 + export default defineEventHandler(async event => { 14 + const session = await requireSession(event) 15 + const row = await enqueue('atproto.publish-pubkey', { 16 + did: session.did, 17 + installationId: session.installationId, 18 + force: true, 19 + }) 20 + return { jobId: row?.id ?? null } 21 + })
+12
server/api/me/whoami.get.ts
··· 1 + import { requireSession } from '~~/server/utils/server-session' 2 + 3 + /** 4 + * Lightweight session probe for the Nuxt `authenticated` middleware. Returns 5 + * the session payload on success, 401 otherwise. The dashboard endpoint 6 + * already requires a session, but the middleware needs a cheap call that 7 + * doesn't touch the DB. 8 + */ 9 + export default defineEventHandler(async event => { 10 + const session = await requireSession(event) 11 + return session 12 + })
+30
server/api/repos/[id]/disable.post.ts
··· 1 + import { and, eq } from 'drizzle-orm' 2 + import { repoMapping } from '~~/server/db/schema' 3 + import { useDb } from '~~/server/utils/db' 4 + import { requireSession } from '~~/server/utils/server-session' 5 + 6 + /** 7 + * Pause sync for one mapping. The worker checks `disabledAt` on every push 8 + * and skips disabled rows; we leave `status` alone so re-enabling restores 9 + * the prior state. 10 + */ 11 + export default defineEventHandler(async event => { 12 + const session = await requireSession(event) 13 + const mappingId = Number(getRouterParam(event, 'id')) 14 + if (!Number.isFinite(mappingId)) { 15 + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 16 + } 17 + 18 + const db = useDb() 19 + const updated = await db.update(repoMapping) 20 + .set({ disabledAt: new Date(), updatedAt: new Date() }) 21 + .where(and( 22 + eq(repoMapping.id, mappingId), 23 + eq(repoMapping.installationId, session.installationId), 24 + )) 25 + .returning({ id: repoMapping.id }) 26 + if (updated.length === 0) { 27 + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) 28 + } 29 + return { ok: true } 30 + })
+26
server/api/repos/[id]/enable.post.ts
··· 1 + import { and, eq } from 'drizzle-orm' 2 + import { repoMapping } from '~~/server/db/schema' 3 + import { useDb } from '~~/server/utils/db' 4 + import { requireSession } from '~~/server/utils/server-session' 5 + 6 + /** Clear `disabledAt`, resuming sync for this mapping. */ 7 + export default defineEventHandler(async event => { 8 + const session = await requireSession(event) 9 + const mappingId = Number(getRouterParam(event, 'id')) 10 + if (!Number.isFinite(mappingId)) { 11 + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 12 + } 13 + 14 + const db = useDb() 15 + const updated = await db.update(repoMapping) 16 + .set({ disabledAt: null, updatedAt: new Date() }) 17 + .where(and( 18 + eq(repoMapping.id, mappingId), 19 + eq(repoMapping.installationId, session.installationId), 20 + )) 21 + .returning({ id: repoMapping.id }) 22 + if (updated.length === 0) { 23 + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) 24 + } 25 + return { ok: true } 26 + })
+39
server/api/repos/[id]/resync.post.ts
··· 1 + import { and, eq } from 'drizzle-orm' 2 + import { repoMapping } from '~~/server/db/schema' 3 + import { useDb } from '~~/server/utils/db' 4 + import { enqueue } from '~~/server/utils/queue' 5 + import { requireSession } from '~~/server/utils/server-session' 6 + 7 + /** 8 + * Enqueue a forced `tangled.create-repo` job for one mapping. The handler 9 + * normally no-ops when a mapping already exists; the `force: true` envelope 10 + * flag tells it to re-run the enrolment flow. 11 + */ 12 + export default defineEventHandler(async event => { 13 + const session = await requireSession(event) 14 + const mappingId = Number(getRouterParam(event, 'id')) 15 + if (!Number.isFinite(mappingId)) { 16 + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 17 + } 18 + 19 + const db = useDb() 20 + const rows = await db.select({ 21 + githubRepoId: repoMapping.githubRepoId, 22 + }) 23 + .from(repoMapping) 24 + .where(and( 25 + eq(repoMapping.id, mappingId), 26 + eq(repoMapping.installationId, session.installationId), 27 + )) 28 + .limit(1) 29 + if (rows.length === 0) { 30 + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) 31 + } 32 + 33 + const row = await enqueue('tangled.create-repo', { 34 + installationId: session.installationId, 35 + githubRepoId: rows[0]!.githubRepoId, 36 + force: true, 37 + }) 38 + return { jobId: row?.id ?? null } 39 + })
+20 -7
server/utils/job-handlers.ts
··· 7 7 import { enqueue } from './queue' 8 8 import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref' 9 9 import { syncPush, type PushPayload } from './sync-push' 10 - import { generateAndPublishKey } from './tangled-pubkey' 10 + import { generateAndPublishKey, rotateKey } from './tangled-pubkey' 11 11 import { enrollRepo, syncRepoMetadata } from './tangled-repo' 12 12 13 13 /** ··· 41 41 interface PublishPubkeyPayload { 42 42 did: string 43 43 installationId: number 44 + /** 45 + * Dashboard "Rotate SSH key" sets this. Causes the handler to call 46 + * `rotateKey()` (delete old PDS record + DB row, then re-publish) rather 47 + * than the no-op-if-exists `generateAndPublishKey()` used at signup. 48 + */ 49 + force?: boolean 44 50 } 45 51 46 52 interface CreateRepoPayload { 47 53 installationId: number 48 54 githubRepoId: number 55 + /** Dashboard "Resync now" sets this; see `enrollRepo` for semantics. */ 56 + force?: boolean 49 57 } 50 58 51 59 interface InstallationRepositoriesPayload { ··· 89 97 if (typeof o.did !== 'string' || typeof o.installationId !== 'number') { 90 98 throw new TypeError('invalid atproto.publish-pubkey payload') 91 99 } 92 - return { did: o.did, installationId: o.installationId } 100 + return { did: o.did, installationId: o.installationId, force: o.force === true } 93 101 } 94 102 95 103 function createRepoPayload(value: unknown): CreateRepoPayload { ··· 97 105 if (typeof o.installationId !== 'number' || typeof o.githubRepoId !== 'number') { 98 106 throw new TypeError('invalid tangled.create-repo payload') 99 107 } 100 - return { installationId: o.installationId, githubRepoId: o.githubRepoId } 108 + return { 109 + installationId: o.installationId, 110 + githubRepoId: o.githubRepoId, 111 + force: o.force === true, 112 + } 101 113 } 102 114 103 115 function backfillInstallationPayload(value: unknown): BackfillInstallationPayload { ··· 198 210 } 199 211 200 212 if (envelope.kind === 'atproto.publish-pubkey') { 201 - const { did, installationId } = publishPubkeyPayload(envelope.payload) 213 + const { did, installationId, force } = publishPubkeyPayload(envelope.payload) 202 214 const client = await useOAuthClient() 203 215 const session = await client.restore(did) 204 - await generateAndPublishKey({ oauthSession: session, installationId }) 216 + if (force) await rotateKey({ oauthSession: session, installationId }) 217 + else await generateAndPublishKey({ oauthSession: session, installationId }) 205 218 return 206 219 } 207 220 208 221 if (envelope.kind === 'tangled.create-repo') { 209 - const { installationId, githubRepoId } = createRepoPayload(envelope.payload) 222 + const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload) 210 223 211 224 // Find the user identity bound to this install. If OAuth hasn't completed 212 225 // yet, drop this job silently \u2014 OAuth callback re-enqueues for all ··· 219 232 220 233 const client = await useOAuthClient() 221 234 const session = await client.restore(identity[0]!.did) 222 - await enrollRepo({ oauthSession: session, installationId, githubRepoId }) 235 + await enrollRepo({ oauthSession: session, installationId, githubRepoId, force }) 223 236 return 224 237 } 225 238
+2
server/utils/require-session.ts
··· 1 + export { getSessionData, requireSession, writeSession } from './server-session' 2 + export type { SynchubSessionData } from './server-session'
+73
server/utils/server-session.ts
··· 1 + import type { H3Event, SessionConfig } from 'h3' 2 + 3 + /** 4 + * Cookie-backed session for the dashboard. The OAuth callback writes the 5 + * session after a successful AT Proto login; `requireSession()` reads it on 6 + * every dashboard / `/api/me/*` / `/api/repos/*` request. 7 + * 8 + * Iron-session-style sealed cookie via h3's built-in `useSession`. The 9 + * `password` comes from `NUXT_SESSION_PASSWORD` (32+ chars). We read 10 + * `process.env` directly so the helper is callable outside a request context 11 + * for tests, mirroring `encryption.ts`. 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. 17 + */ 18 + export interface SynchubSessionData { 19 + did: string 20 + installationId: number 21 + handle?: string 22 + } 23 + 24 + const COOKIE_NAME = 'synchub-session' 25 + const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days 26 + 27 + function sessionPassword(): string { 28 + const raw = process.env.NUXT_SESSION_PASSWORD 29 + if (!raw || raw.length < 32) { 30 + throw new Error('NUXT_SESSION_PASSWORD is not set (need 32+ characters of entropy)') 31 + } 32 + return raw 33 + } 34 + 35 + export function sessionConfig(): SessionConfig { 36 + return { 37 + name: COOKIE_NAME, 38 + password: sessionPassword(), 39 + maxAge: COOKIE_MAX_AGE_SECONDS, 40 + cookie: { 41 + httpOnly: true, 42 + sameSite: 'lax', 43 + secure: !(process.env.NUXT_PUBLIC_URL?.startsWith('http://127.0.0.1') 44 + || process.env.NUXT_PUBLIC_URL?.startsWith('http://localhost')), 45 + path: '/', 46 + }, 47 + } 48 + } 49 + 50 + export async function getSessionData(event: H3Event): Promise<SynchubSessionData | null> { 51 + 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 } 55 + } 56 + 57 + /** 58 + * Return the current session or throw a 401. Use from any handler that needs 59 + * an authenticated user. The thrown error is consumed by Nitro and rendered 60 + * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`. 61 + */ 62 + export async function requireSession(event: H3Event): Promise<SynchubSessionData> { 63 + const data = await getSessionData(event) 64 + if (!data) { 65 + throw createError({ statusCode: 401, statusMessage: 'unauthenticated' }) 66 + } 67 + return data 68 + } 69 + 70 + export async function writeSession(event: H3Event, data: SynchubSessionData): Promise<void> { 71 + const session = await useSession<SynchubSessionData>(event, sessionConfig()) 72 + await session.update(data) 73 + }
+48
server/utils/tangled-pubkey.ts
··· 67 67 68 68 return { created: true } 69 69 } 70 + 71 + /** 72 + * Rotate the SSH key for `(installationId, did)`. 73 + * 74 + * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the 75 + * record is already gone on the PDS we proceed), drop the local row, then 76 + * fall through to `generateAndPublishKey` to mint a fresh key. Pushes 77 + * already in flight with the old key will fail and get retried with the new 78 + * one via the queue's normal backoff. 79 + */ 80 + export async function rotateKey(opts: { 81 + oauthSession: OAuthSession 82 + installationId: number 83 + keyName?: string 84 + }): Promise<{ created: boolean }> { 85 + const db = useDb() 86 + const did = opts.oauthSession.did 87 + 88 + const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) 89 + .from(sshKey) 90 + .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) 91 + 92 + if (existing.length > 0) { 93 + const row = existing[0]! 94 + if (row.rkey) { 95 + const agent = new Agent(opts.oauthSession) 96 + try { 97 + await agent.com.atproto.repo.deleteRecord({ 98 + repo: did, 99 + collection: PUBKEY_LEXICON, 100 + rkey: row.rkey, 101 + }) 102 + } 103 + catch (err) { 104 + // If the record is already gone (404) we can safely continue; any 105 + // other error means the PDS rejected the delete and we should bail 106 + // rather than leave the user with two records. 107 + const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 108 + ? err.status 109 + : undefined 110 + if (status !== 404) throw err 111 + } 112 + } 113 + await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 114 + } 115 + 116 + return generateAndPublishKey(opts) 117 + }
+39 -12
server/utils/tangled-repo.ts
··· 111 111 oauthSession: OAuthSession 112 112 installationId: number 113 113 githubRepoId: number 114 + /** 115 + * Used by the dashboard "Resync now" action. When true, ignore an existing 116 + * `repo_mapping` row in `active` state and re-run the enrolment flow. Note 117 + * this still performs the knot procedure call, which mints a *new* 118 + * `repoDid`; v1 then overwrites the mapping with the new identity. A 119 + * more surgical "poke the knot to re-sync from source" path is a future 120 + * improvement. 121 + */ 122 + force?: boolean 114 123 }): Promise<EnrolResult> { 115 124 const db = useDb() 116 125 117 - const existing = await db.select({ id: repoMapping.id }) 126 + const existing = await db.select({ id: repoMapping.id, status: repoMapping.status }) 118 127 .from(repoMapping) 119 128 .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`) 120 - if (existing.length > 0) { 129 + if (existing.length > 0 && !opts.force) { 121 130 return { status: 'already' } 122 131 } 123 132 ··· 195 204 record, 196 205 }) 197 206 198 - // 6. Persist mapping. 199 - await db.insert(repoMapping).values({ 200 - installationId: opts.installationId, 201 - githubRepoId: opts.githubRepoId, 202 - githubFullName: repo.full_name, 203 - tangledRepoDid: repoDid, 204 - tangledFullName: `${opts.oauthSession.did}/${name}`, 205 - knot, 206 - status: 'active', 207 - }) 207 + // 6. Persist mapping. On a forced resync the row already exists; update 208 + // in place so we retain `lastSyncedRefs` (the worker uses it for ref-tip 209 + // dedupe) but refresh the tangled-side identifiers and clear any prior 210 + // error. 211 + if (existing.length > 0) { 212 + await db.update(repoMapping) 213 + .set({ 214 + githubFullName: repo.full_name, 215 + tangledRepoDid: repoDid, 216 + tangledFullName: `${opts.oauthSession.did}/${name}`, 217 + knot, 218 + status: 'active', 219 + lastError: null, 220 + updatedAt: new Date(), 221 + }) 222 + .where(sql`${repoMapping.id} = ${existing[0]!.id}`) 223 + } 224 + else { 225 + await db.insert(repoMapping).values({ 226 + installationId: opts.installationId, 227 + githubRepoId: opts.githubRepoId, 228 + githubFullName: repo.full_name, 229 + tangledRepoDid: repoDid, 230 + tangledFullName: `${opts.oauthSession.did}/${name}`, 231 + knot, 232 + status: 'active', 233 + }) 234 + } 208 235 209 236 return { status: 'enrolled' } 210 237 }
+49
test/unit/server-session.spec.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 2 + import { sessionConfig } from '../../server/utils/server-session' 3 + 4 + const ORIGINAL_PASSWORD = process.env.NUXT_SESSION_PASSWORD 5 + const ORIGINAL_PUBLIC_URL = process.env.NUXT_PUBLIC_URL 6 + 7 + describe('server-session: sessionConfig', () => { 8 + beforeEach(() => { 9 + process.env.NUXT_SESSION_PASSWORD = 'a'.repeat(32) 10 + process.env.NUXT_PUBLIC_URL = 'http://127.0.0.1:3000' 11 + }) 12 + 13 + afterEach(() => { 14 + if (ORIGINAL_PASSWORD === undefined) delete process.env.NUXT_SESSION_PASSWORD 15 + else process.env.NUXT_SESSION_PASSWORD = ORIGINAL_PASSWORD 16 + if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.NUXT_PUBLIC_URL 17 + else process.env.NUXT_PUBLIC_URL = ORIGINAL_PUBLIC_URL 18 + }) 19 + 20 + it('throws if NUXT_SESSION_PASSWORD is missing', () => { 21 + delete process.env.NUXT_SESSION_PASSWORD 22 + expect(() => sessionConfig()).toThrow(/NUXT_SESSION_PASSWORD/) 23 + }) 24 + 25 + it('throws if NUXT_SESSION_PASSWORD is too short', () => { 26 + process.env.NUXT_SESSION_PASSWORD = 'short' 27 + expect(() => sessionConfig()).toThrow(/32\+ characters/) 28 + }) 29 + 30 + it('marks the cookie non-secure on 127.0.0.1 loopback dev', () => { 31 + process.env.NUXT_PUBLIC_URL = 'http://127.0.0.1:3000' 32 + const config = sessionConfig() 33 + expect(config.cookie).toMatchObject({ secure: false, sameSite: 'lax', httpOnly: true, path: '/' }) 34 + expect(config.name).toBe('synchub-session') 35 + expect(config.maxAge).toBe(60 * 60 * 24 * 30) 36 + }) 37 + 38 + it('marks the cookie non-secure on localhost loopback dev', () => { 39 + process.env.NUXT_PUBLIC_URL = 'http://localhost:3000' 40 + const config = sessionConfig() 41 + expect(config.cookie).toMatchObject({ secure: false }) 42 + }) 43 + 44 + it('marks the cookie secure when the deploy URL is not loopback', () => { 45 + process.env.NUXT_PUBLIC_URL = 'https://synchub.to' 46 + const config = sessionConfig() 47 + expect(config.cookie).toMatchObject({ secure: true }) 48 + }) 49 + })
+116 -1
test/unit/tangled-pubkey.spec.ts
··· 4 4 import { installation, sshKey } from '../../server/db/schema' 5 5 import { clearDb, setDb, useDb } from '../../server/utils/db' 6 6 import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption' 7 - import { generateAndPublishKey } from '../../server/utils/tangled-pubkey' 7 + import { generateAndPublishKey, rotateKey } from '../../server/utils/tangled-pubkey' 8 8 import { createTestDb } from '../utils/db' 9 9 10 10 const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 11 11 12 12 const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>() 13 + const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>() 13 14 14 15 vi.mock('@atproto/api', () => ({ 15 16 Agent: class { ··· 17 18 atproto: { 18 19 repo: { 19 20 createRecord: createRecordMock, 21 + deleteRecord: deleteRecordMock, 20 22 }, 21 23 }, 22 24 } ··· 45 47 }) 46 48 47 49 createRecordMock.mockReset() 50 + deleteRecordMock.mockReset() 48 51 createRecordMock.mockResolvedValue({ 49 52 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 50 53 }) 54 + deleteRecordMock.mockResolvedValue({}) 51 55 }) 52 56 53 57 afterEach(() => { ··· 117 121 expect(rows).toHaveLength(0) 118 122 }) 119 123 }) 124 + 125 + describe('rotateKey', () => { 126 + beforeEach(async () => { 127 + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 128 + clearEncryptionKeyCache() 129 + 130 + setDb(await createTestDb()) 131 + await useDb().insert(installation).values({ 132 + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 133 + }) 134 + 135 + createRecordMock.mockReset() 136 + deleteRecordMock.mockReset() 137 + let counter = 0 138 + createRecordMock.mockImplementation(async () => { 139 + counter += 1 140 + return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } 141 + }) 142 + deleteRecordMock.mockResolvedValue({}) 143 + }) 144 + 145 + afterEach(() => { 146 + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 147 + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 148 + clearEncryptionKeyCache() 149 + clearDb() 150 + }) 151 + 152 + it('deletes the old PDS record and publishes a fresh key', async () => { 153 + await generateAndPublishKey({ 154 + oauthSession: fakeOauthSession('did:plc:abc'), 155 + installationId: 1, 156 + }) 157 + const db = useDb() 158 + const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 159 + expect(before).toHaveLength(1) 160 + const oldPubKey = before[0].publicKey 161 + const oldRkey = before[0].tangledKeyRkey 162 + 163 + const result = await rotateKey({ 164 + oauthSession: fakeOauthSession('did:plc:abc'), 165 + installationId: 1, 166 + }) 167 + expect(result.created).toBe(true) 168 + 169 + expect(deleteRecordMock).toHaveBeenCalledTimes(1) 170 + const del = deleteRecordMock.mock.calls[0][0] 171 + expect(del.repo).toBe('did:plc:abc') 172 + expect(del.collection).toBe('sh.tangled.publicKey') 173 + expect(del.rkey).toBe(oldRkey) 174 + 175 + const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 176 + expect(after).toHaveLength(1) 177 + expect(after[0].publicKey).not.toBe(oldPubKey) 178 + expect(after[0].tangledKeyRkey).toBe('rkey-2') 179 + }) 180 + 181 + it('proceeds when the PDS reports the record is already gone (404)', async () => { 182 + await generateAndPublishKey({ 183 + oauthSession: fakeOauthSession('did:plc:abc'), 184 + installationId: 1, 185 + }) 186 + 187 + const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 }) 188 + deleteRecordMock.mockRejectedValueOnce(notFound) 189 + 190 + const result = await rotateKey({ 191 + oauthSession: fakeOauthSession('did:plc:abc'), 192 + installationId: 1, 193 + }) 194 + expect(result.created).toBe(true) 195 + 196 + const db = useDb() 197 + const rows = await db.select().from(sshKey) 198 + expect(rows).toHaveLength(1) 199 + expect(rows[0].tangledKeyRkey).toBe('rkey-2') 200 + }) 201 + 202 + it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => { 203 + await generateAndPublishKey({ 204 + oauthSession: fakeOauthSession('did:plc:abc'), 205 + installationId: 1, 206 + }) 207 + 208 + deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) 209 + 210 + await expect(rotateKey({ 211 + oauthSession: fakeOauthSession('did:plc:abc'), 212 + installationId: 1, 213 + })).rejects.toThrow(/boom/) 214 + 215 + // Old row still present, no second createRecord call. 216 + const db = useDb() 217 + const rows = await db.select().from(sshKey) 218 + expect(rows).toHaveLength(1) 219 + expect(rows[0].tangledKeyRkey).toBe('rkey-1') 220 + expect(createRecordMock).toHaveBeenCalledTimes(1) 221 + }) 222 + 223 + it('mints a fresh key even if there is no existing row', async () => { 224 + const result = await rotateKey({ 225 + oauthSession: fakeOauthSession('did:plc:abc'), 226 + installationId: 1, 227 + }) 228 + expect(result.created).toBe(true) 229 + expect(deleteRecordMock).not.toHaveBeenCalled() 230 + const db = useDb() 231 + const rows = await db.select().from(sshKey) 232 + expect(rows).toHaveLength(1) 233 + }) 234 + })
+38
test/unit/tangled-repo.spec.ts
··· 204 204 expect(githubGet).not.toHaveBeenCalled() 205 205 }) 206 206 207 + it('re-enrolls when force=true and updates the existing mapping in place', async () => { 208 + await useDb().insert(repoMapping).values({ 209 + installationId: 1, 210 + githubRepoId: 9001, 211 + githubFullName: 'alice/my-project', 212 + tangledRepoDid: 'did:plc:old-repo', 213 + tangledFullName: 'did:plc:abc/my-project', 214 + knot: 'knot1.tangled.sh', 215 + status: 'error', 216 + lastError: 'previous failure', 217 + lastSyncedRefs: { 'refs/heads/main': 'deadbeef' }, 218 + }) 219 + 220 + githubGet.mockResolvedValue({ data: ghRepo() }) 221 + fakeFetch.mockResolvedValue(new Response( 222 + JSON.stringify({ repoDid: 'did:plc:repo-new' }), 223 + { status: 200 }, 224 + )) 225 + 226 + const result = await enrollRepo({ 227 + oauthSession: fakeOauthSession('did:plc:abc'), 228 + installationId: 1, 229 + githubRepoId: 9001, 230 + force: true, 231 + }) 232 + expect(result.status).toBe('enrolled') 233 + 234 + const rows = await useDb().select().from(repoMapping) 235 + .where(sql`${repoMapping.installationId} = 1`) 236 + expect(rows).toHaveLength(1) 237 + expect(rows[0].tangledRepoDid).toBe('did:plc:repo-new') 238 + expect(rows[0].status).toBe('active') 239 + expect(rows[0].lastError).toBeNull() 240 + // lastSyncedRefs preserved across the resync so the push worker keeps 241 + // dedupe state. 242 + expect(rows[0].lastSyncedRefs).toEqual({ 'refs/heads/main': 'deadbeef' }) 243 + }) 244 + 207 245 it('throws and writes nothing if the knot rejects the procedure', async () => { 208 246 githubGet.mockResolvedValue({ data: ghRepo() }) 209 247 fakeFetch.mockResolvedValue(new Response('nope', { status: 500 }))