···3939NUXT_ENCRYPTION_KEY=<base64-encoded 32 bytes>
40404141# ---------------------------------------------------------------------------
4242+# Dashboard session password. Used by h3's `useSession` to seal the
4343+# `synchub-session` cookie. 32+ characters of entropy.
4444+# Generate with: pnpm gen:encryption-key (any sufficiently long random string
4545+# works; the base64 output of 32 random bytes is convenient).
4646+# ---------------------------------------------------------------------------
4747+NUXT_SESSION_PASSWORD=<32+ char random string>
4848+4949+# ---------------------------------------------------------------------------
4250# GitHub App credentials. After creating the App at
4351# https://github.com/settings/apps/new, copy:
4452# - The numeric App ID (top of the App settings page).
+23
app/middleware/authenticated.ts
···11+export default defineNuxtRouteMiddleware(async () => {
22+ // On SSR we must forward the request cookies so the whoami probe sees the
33+ // session. On the client, same-origin `$fetch` already sends cookies.
44+ const headers: Record<string, string> = {}
55+ if (import.meta.server) {
66+ const cookie = useRequestHeader('cookie')
77+ if (cookie) headers.cookie = cookie
88+ }
99+1010+ try {
1111+ await $fetch('/api/me/whoami', { headers })
1212+ }
1313+ catch (err: unknown) {
1414+ const status = err && typeof err === 'object'
1515+ ? (('statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : undefined)
1616+ ?? ('status' in err && typeof err.status === 'number' ? err.status : undefined))
1717+ : undefined
1818+ if (status === 401) {
1919+ return navigateTo('/', { redirectCode: 302 })
2020+ }
2121+ throw err
2222+ }
2323+})
···11+import { enqueue } from '~~/server/utils/queue'
22+import { requireSession } from '~~/server/utils/server-session'
33+44+/**
55+ * Enqueue an SSH key rotation for the current `(did, installationId)`.
66+ *
77+ * The actual rotation runs in the worker: delete the existing
88+ * `sh.tangled.publicKey` PDS record, drop the local row, generate a fresh
99+ * keypair, publish the new public half. Doing this via the queue (rather
1010+ * than inline like the signup path) means a slow PDS doesn't tie up the
1111+ * request and we get retry semantics for free.
1212+ */
1313+export default defineEventHandler(async event => {
1414+ const session = await requireSession(event)
1515+ const row = await enqueue('atproto.publish-pubkey', {
1616+ did: session.did,
1717+ installationId: session.installationId,
1818+ force: true,
1919+ })
2020+ return { jobId: row?.id ?? null }
2121+})
+12
server/api/me/whoami.get.ts
···11+import { requireSession } from '~~/server/utils/server-session'
22+33+/**
44+ * Lightweight session probe for the Nuxt `authenticated` middleware. Returns
55+ * the session payload on success, 401 otherwise. The dashboard endpoint
66+ * already requires a session, but the middleware needs a cheap call that
77+ * doesn't touch the DB.
88+ */
99+export default defineEventHandler(async event => {
1010+ const session = await requireSession(event)
1111+ return session
1212+})
+30
server/api/repos/[id]/disable.post.ts
···11+import { and, eq } from 'drizzle-orm'
22+import { repoMapping } from '~~/server/db/schema'
33+import { useDb } from '~~/server/utils/db'
44+import { requireSession } from '~~/server/utils/server-session'
55+66+/**
77+ * Pause sync for one mapping. The worker checks `disabledAt` on every push
88+ * and skips disabled rows; we leave `status` alone so re-enabling restores
99+ * the prior state.
1010+ */
1111+export default defineEventHandler(async event => {
1212+ const session = await requireSession(event)
1313+ const mappingId = Number(getRouterParam(event, 'id'))
1414+ if (!Number.isFinite(mappingId)) {
1515+ throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' })
1616+ }
1717+1818+ const db = useDb()
1919+ const updated = await db.update(repoMapping)
2020+ .set({ disabledAt: new Date(), updatedAt: new Date() })
2121+ .where(and(
2222+ eq(repoMapping.id, mappingId),
2323+ eq(repoMapping.installationId, session.installationId),
2424+ ))
2525+ .returning({ id: repoMapping.id })
2626+ if (updated.length === 0) {
2727+ throw createError({ statusCode: 404, statusMessage: 'mapping not found' })
2828+ }
2929+ return { ok: true }
3030+})
···11+import { and, eq } from 'drizzle-orm'
22+import { repoMapping } from '~~/server/db/schema'
33+import { useDb } from '~~/server/utils/db'
44+import { enqueue } from '~~/server/utils/queue'
55+import { requireSession } from '~~/server/utils/server-session'
66+77+/**
88+ * Enqueue a forced `tangled.create-repo` job for one mapping. The handler
99+ * normally no-ops when a mapping already exists; the `force: true` envelope
1010+ * flag tells it to re-run the enrolment flow.
1111+ */
1212+export default defineEventHandler(async event => {
1313+ const session = await requireSession(event)
1414+ const mappingId = Number(getRouterParam(event, 'id'))
1515+ if (!Number.isFinite(mappingId)) {
1616+ throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' })
1717+ }
1818+1919+ const db = useDb()
2020+ const rows = await db.select({
2121+ githubRepoId: repoMapping.githubRepoId,
2222+ })
2323+ .from(repoMapping)
2424+ .where(and(
2525+ eq(repoMapping.id, mappingId),
2626+ eq(repoMapping.installationId, session.installationId),
2727+ ))
2828+ .limit(1)
2929+ if (rows.length === 0) {
3030+ throw createError({ statusCode: 404, statusMessage: 'mapping not found' })
3131+ }
3232+3333+ const row = await enqueue('tangled.create-repo', {
3434+ installationId: session.installationId,
3535+ githubRepoId: rows[0]!.githubRepoId,
3636+ force: true,
3737+ })
3838+ return { jobId: row?.id ?? null }
3939+})
+20-7
server/utils/job-handlers.ts
···77import { enqueue } from './queue'
88import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref'
99import { syncPush, type PushPayload } from './sync-push'
1010-import { generateAndPublishKey } from './tangled-pubkey'
1010+import { generateAndPublishKey, rotateKey } from './tangled-pubkey'
1111import { enrollRepo, syncRepoMetadata } from './tangled-repo'
12121313/**
···4141interface PublishPubkeyPayload {
4242 did: string
4343 installationId: number
4444+ /**
4545+ * Dashboard "Rotate SSH key" sets this. Causes the handler to call
4646+ * `rotateKey()` (delete old PDS record + DB row, then re-publish) rather
4747+ * than the no-op-if-exists `generateAndPublishKey()` used at signup.
4848+ */
4949+ force?: boolean
4450}
45514652interface CreateRepoPayload {
4753 installationId: number
4854 githubRepoId: number
5555+ /** Dashboard "Resync now" sets this; see `enrollRepo` for semantics. */
5656+ force?: boolean
4957}
50585159interface InstallationRepositoriesPayload {
···8997 if (typeof o.did !== 'string' || typeof o.installationId !== 'number') {
9098 throw new TypeError('invalid atproto.publish-pubkey payload')
9199 }
9292- return { did: o.did, installationId: o.installationId }
100100+ return { did: o.did, installationId: o.installationId, force: o.force === true }
93101}
9410295103function createRepoPayload(value: unknown): CreateRepoPayload {
···97105 if (typeof o.installationId !== 'number' || typeof o.githubRepoId !== 'number') {
98106 throw new TypeError('invalid tangled.create-repo payload')
99107 }
100100- return { installationId: o.installationId, githubRepoId: o.githubRepoId }
108108+ return {
109109+ installationId: o.installationId,
110110+ githubRepoId: o.githubRepoId,
111111+ force: o.force === true,
112112+ }
101113}
102114103115function backfillInstallationPayload(value: unknown): BackfillInstallationPayload {
···198210 }
199211200212 if (envelope.kind === 'atproto.publish-pubkey') {
201201- const { did, installationId } = publishPubkeyPayload(envelope.payload)
213213+ const { did, installationId, force } = publishPubkeyPayload(envelope.payload)
202214 const client = await useOAuthClient()
203215 const session = await client.restore(did)
204204- await generateAndPublishKey({ oauthSession: session, installationId })
216216+ if (force) await rotateKey({ oauthSession: session, installationId })
217217+ else await generateAndPublishKey({ oauthSession: session, installationId })
205218 return
206219 }
207220208221 if (envelope.kind === 'tangled.create-repo') {
209209- const { installationId, githubRepoId } = createRepoPayload(envelope.payload)
222222+ const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload)
210223211224 // Find the user identity bound to this install. If OAuth hasn't completed
212225 // yet, drop this job silently \u2014 OAuth callback re-enqueues for all
···219232220233 const client = await useOAuthClient()
221234 const session = await client.restore(identity[0]!.did)
222222- await enrollRepo({ oauthSession: session, installationId, githubRepoId })
235235+ await enrollRepo({ oauthSession: session, installationId, githubRepoId, force })
223236 return
224237 }
225238
+2
server/utils/require-session.ts
···11+export { getSessionData, requireSession, writeSession } from './server-session'
22+export type { SynchubSessionData } from './server-session'
+73
server/utils/server-session.ts
···11+import type { H3Event, SessionConfig } from 'h3'
22+33+/**
44+ * Cookie-backed session for the dashboard. The OAuth callback writes the
55+ * session after a successful AT Proto login; `requireSession()` reads it on
66+ * every dashboard / `/api/me/*` / `/api/repos/*` request.
77+ *
88+ * Iron-session-style sealed cookie via h3's built-in `useSession`. The
99+ * `password` comes from `NUXT_SESSION_PASSWORD` (32+ chars). We read
1010+ * `process.env` directly so the helper is callable outside a request context
1111+ * for tests, mirroring `encryption.ts`.
1212+ *
1313+ * v1 stores `installationId` alongside `did`: a user may have installed the
1414+ * GitHub App on more than one account (personal + an org), but the session
1515+ * pins us to the one they last authenticated against. See the dashboard
1616+ * "Connect a different installation?" link for the workaround.
1717+ */
1818+export interface SynchubSessionData {
1919+ did: string
2020+ installationId: number
2121+ handle?: string
2222+}
2323+2424+const COOKIE_NAME = 'synchub-session'
2525+const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days
2626+2727+function sessionPassword(): string {
2828+ const raw = process.env.NUXT_SESSION_PASSWORD
2929+ if (!raw || raw.length < 32) {
3030+ throw new Error('NUXT_SESSION_PASSWORD is not set (need 32+ characters of entropy)')
3131+ }
3232+ return raw
3333+}
3434+3535+export function sessionConfig(): SessionConfig {
3636+ return {
3737+ name: COOKIE_NAME,
3838+ password: sessionPassword(),
3939+ maxAge: COOKIE_MAX_AGE_SECONDS,
4040+ cookie: {
4141+ httpOnly: true,
4242+ sameSite: 'lax',
4343+ secure: !(process.env.NUXT_PUBLIC_URL?.startsWith('http://127.0.0.1')
4444+ || process.env.NUXT_PUBLIC_URL?.startsWith('http://localhost')),
4545+ path: '/',
4646+ },
4747+ }
4848+}
4949+5050+export async function getSessionData(event: H3Event): Promise<SynchubSessionData | null> {
5151+ const session = await useSession<SynchubSessionData>(event, sessionConfig())
5252+ const { did, installationId } = session.data
5353+ if (typeof did !== 'string' || typeof installationId !== 'number') return null
5454+ return { did, installationId, handle: session.data.handle }
5555+}
5656+5757+/**
5858+ * Return the current session or throw a 401. Use from any handler that needs
5959+ * an authenticated user. The thrown error is consumed by Nitro and rendered
6060+ * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`.
6161+ */
6262+export async function requireSession(event: H3Event): Promise<SynchubSessionData> {
6363+ const data = await getSessionData(event)
6464+ if (!data) {
6565+ throw createError({ statusCode: 401, statusMessage: 'unauthenticated' })
6666+ }
6767+ return data
6868+}
6969+7070+export async function writeSession(event: H3Event, data: SynchubSessionData): Promise<void> {
7171+ const session = await useSession<SynchubSessionData>(event, sessionConfig())
7272+ await session.update(data)
7373+}
+48
server/utils/tangled-pubkey.ts
···67676868 return { created: true }
6969}
7070+7171+/**
7272+ * Rotate the SSH key for `(installationId, did)`.
7373+ *
7474+ * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the
7575+ * record is already gone on the PDS we proceed), drop the local row, then
7676+ * fall through to `generateAndPublishKey` to mint a fresh key. Pushes
7777+ * already in flight with the old key will fail and get retried with the new
7878+ * one via the queue's normal backoff.
7979+ */
8080+export async function rotateKey(opts: {
8181+ oauthSession: OAuthSession
8282+ installationId: number
8383+ keyName?: string
8484+}): Promise<{ created: boolean }> {
8585+ const db = useDb()
8686+ const did = opts.oauthSession.did
8787+8888+ const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey })
8989+ .from(sshKey)
9090+ .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`)
9191+9292+ if (existing.length > 0) {
9393+ const row = existing[0]!
9494+ if (row.rkey) {
9595+ const agent = new Agent(opts.oauthSession)
9696+ try {
9797+ await agent.com.atproto.repo.deleteRecord({
9898+ repo: did,
9999+ collection: PUBKEY_LEXICON,
100100+ rkey: row.rkey,
101101+ })
102102+ }
103103+ catch (err) {
104104+ // If the record is already gone (404) we can safely continue; any
105105+ // other error means the PDS rejected the delete and we should bail
106106+ // rather than leave the user with two records.
107107+ const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
108108+ ? err.status
109109+ : undefined
110110+ if (status !== 404) throw err
111111+ }
112112+ }
113113+ await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
114114+ }
115115+116116+ return generateAndPublishKey(opts)
117117+}
+39-12
server/utils/tangled-repo.ts
···111111 oauthSession: OAuthSession
112112 installationId: number
113113 githubRepoId: number
114114+ /**
115115+ * Used by the dashboard "Resync now" action. When true, ignore an existing
116116+ * `repo_mapping` row in `active` state and re-run the enrolment flow. Note
117117+ * this still performs the knot procedure call, which mints a *new*
118118+ * `repoDid`; v1 then overwrites the mapping with the new identity. A
119119+ * more surgical "poke the knot to re-sync from source" path is a future
120120+ * improvement.
121121+ */
122122+ force?: boolean
114123}): Promise<EnrolResult> {
115124 const db = useDb()
116125117117- const existing = await db.select({ id: repoMapping.id })
126126+ const existing = await db.select({ id: repoMapping.id, status: repoMapping.status })
118127 .from(repoMapping)
119128 .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`)
120120- if (existing.length > 0) {
129129+ if (existing.length > 0 && !opts.force) {
121130 return { status: 'already' }
122131 }
123132···195204 record,
196205 })
197206198198- // 6. Persist mapping.
199199- await db.insert(repoMapping).values({
200200- installationId: opts.installationId,
201201- githubRepoId: opts.githubRepoId,
202202- githubFullName: repo.full_name,
203203- tangledRepoDid: repoDid,
204204- tangledFullName: `${opts.oauthSession.did}/${name}`,
205205- knot,
206206- status: 'active',
207207- })
207207+ // 6. Persist mapping. On a forced resync the row already exists; update
208208+ // in place so we retain `lastSyncedRefs` (the worker uses it for ref-tip
209209+ // dedupe) but refresh the tangled-side identifiers and clear any prior
210210+ // error.
211211+ if (existing.length > 0) {
212212+ await db.update(repoMapping)
213213+ .set({
214214+ githubFullName: repo.full_name,
215215+ tangledRepoDid: repoDid,
216216+ tangledFullName: `${opts.oauthSession.did}/${name}`,
217217+ knot,
218218+ status: 'active',
219219+ lastError: null,
220220+ updatedAt: new Date(),
221221+ })
222222+ .where(sql`${repoMapping.id} = ${existing[0]!.id}`)
223223+ }
224224+ else {
225225+ await db.insert(repoMapping).values({
226226+ installationId: opts.installationId,
227227+ githubRepoId: opts.githubRepoId,
228228+ githubFullName: repo.full_name,
229229+ tangledRepoDid: repoDid,
230230+ tangledFullName: `${opts.oauthSession.did}/${name}`,
231231+ knot,
232232+ status: 'active',
233233+ })
234234+ }
208235209236 return { status: 'enrolled' }
210237}