···22import { repoMapping } from '#server/db/schema'
33import { useDb } from '#server/utils/db'
44import { enqueue } from '#server/utils/queue'
55-import { requireSession } from '#server/utils/server-session'
55+import { requireSessionAndMappingId } from '#server/utils/repo-route'
6677/**
88 * Enqueue a forced `tangled.create-repo` job for one mapping. The handler
···1010 * flag tells it to re-run the enrolment flow.
1111 */
1212export 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- }
1313+ const { session, mappingId } = await requireSessionAndMappingId(event)
18141915 const db = useDb()
2016 const rows = await db.select({
+4-5
server/db/schema.ts
···130130 check('webhook_event_source_chk', sql`${table.source} in ('github','tangled')`),
131131])
132132133133-// AT Protocol OAuth stores. The values are encrypted at rest (libsodium sealed
134134-// box) because they contain access tokens, refresh tokens, and the DPoP private
135135-// key for the user's PDS. The encryption layer wraps the OAuth library's store
136136-// interface (encrypt in set, decrypt in get); see commit 9 (`feat: generate
137137-// per-install ssh key and publish publickey record`) for the shared helper.
133133+// AT Protocol OAuth stores. The values are encrypted at rest because they
134134+// contain access tokens, refresh tokens, and the DPoP private key for the
135135+// user's PDS. The encryption layer wraps the OAuth library's store interface
136136+// (encrypt in set, decrypt in get).
138137export const atprotoState = pgTable('atproto_state', {
139138 key: text('key').primaryKey(),
140139 valueCiphertext: bytea('value_ciphertext').notNull(),
+1-1
server/utils/encryption.ts
···77 * before it lands in the DB: AT Proto session blobs, SSH private keys.
88 *
99 * The KEK is held only in env. If it's lost, every encrypted row becomes
1010- * unreadable. KEK rotation is a future concern \u2014 see PLAN.md.
1010+ * unreadable. KEK rotation is a future concern; see PLAN.md.
1111 */
1212const NONCE_BYTES = 24
1313let cachedKey: Uint8Array | undefined
+6
server/utils/github-app.ts
···3232 return app.getInstallationOctokit(installationId)
3333}
34343535+/** Mint a short-lived installation access token for authenticating git fetches. */
3636+export async function installationToken(octokit: InstallationOctokit): Promise<string> {
3737+ const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
3838+ return token
3939+}
4040+3541function requireOAuthApp(): App {
3642 if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) {
3743 throw createError({
+18-33
server/utils/job-handlers.ts
···11+import type { OAuthSession } from '@atproto/oauth-client-node'
12import { and, eq, sql } from 'drizzle-orm'
23import { repoMapping, userIdentity } from '../db/schema'
34import { useOAuthClient } from './atproto-oauth'
···1011import { generateAndPublishKey, rotateKey } from './tangled-pubkey'
1112import { enrollRepo, syncRepoMetadata } from './tangled-repo'
12131313-/**
1414- * Map of job kind → handler. Each commit fills in its slice:
1515- * - 'github.push' → commit 12 (sync push events)
1616- * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops)
1717- * - 'github.repository' → metadata sync + lifecycle (edited,
1818- * renamed, privatized, publicized,
1919- * transferred, deleted)
2020- * - 'github.installation_repositories' → commit 10 (fan-out enrolment)
2121- * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out)
2222- * - 'tangled.create-repo' → commit 10 (per-repo enrolment)
2323- * - 'atproto.publish-pubkey' → commit 9
2424- *
2525- * Unknown kinds throw so they surface as job failures rather than silent
2626- * acknowledgement.
2727- */
1414+/** Job kinds `dispatch` understands; anything else throws as a job failure. */
2815const KNOWN_KINDS = new Set([
2916 'github.push',
3017 'github.create',
···220207221208 if (envelope.kind === 'tangled.create-repo') {
222209 const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload)
223223-224224- // Find the user identity bound to this install. If OAuth hasn't completed
225225- // yet, drop this job silently \u2014 OAuth callback re-enqueues for all
226226- // accessible repos at completion time, so we'll get a fresh trigger.
227227- const db = useDb()
228228- const identity = await db.select({ did: userIdentity.did })
229229- .from(userIdentity)
230230- .where(sql`${userIdentity.installationId} = ${installationId}`)
231231- if (identity.length === 0) return
232232-233233- const client = await useOAuthClient()
234234- const session = await client.restore(identity[0]!.did)
210210+ const session = await restoreSessionForInstallation(installationId)
211211+ if (!session) return
235212 await enrollRepo({ oauthSession: session, installationId, githubRepoId, force })
236213 return
237214 }
···295272 await handleRepositoryEvent(repositoryPayload(envelope.payload))
296273 return
297274 }
298298-299299- // Other kinds: still no-op until handlers land in their commits.
300275}
301276302277async function handleRepositoryEvent(payload: RepositoryPayload): Promise<void> {
···388363}
389364390365async function runMetadataSync(installationId: number, githubRepoId: number): Promise<void> {
366366+ const session = await restoreSessionForInstallation(installationId)
367367+ if (!session) return
368368+ await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId })
369369+}
370370+371371+/**
372372+ * Restore the OAuth session for the user identity bound to `installationId`,
373373+ * or null if OAuth hasn't completed yet. The OAuth callback re-enqueues work
374374+ * for all accessible repos on completion, so a null here is a benign drop: a
375375+ * fresh trigger arrives once the identity exists.
376376+ */
377377+async function restoreSessionForInstallation(installationId: number): Promise<OAuthSession | null> {
391378 const db = useDb()
392379 const identity = await db.select({ did: userIdentity.did })
393380 .from(userIdentity)
394381 .where(sql`${userIdentity.installationId} = ${installationId}`)
395395- // No tangled identity yet — OAuth callback will backfill on completion.
396396- if (identity.length === 0) return
382382+ if (identity.length === 0) return null
397383398384 const client = await useOAuthClient()
399399- const session = await client.restore(identity[0]!.did)
400400- await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId })
385385+ return client.restore(identity[0]!.did)
401386}
+101
server/utils/repo-mapping.ts
···11+import { and, eq, sql } from 'drizzle-orm'
22+import { repoMapping } from '../db/schema'
33+import { useDb } from './db'
44+import { RemoteRejectedError } from './git-wire/errors'
55+66+export interface ActiveMapping {
77+ id: number
88+ githubFullName: string
99+ tangledRepoDid: string
1010+ knot: string
1111+ lastSyncedRefs: Record<string, string>
1212+}
1313+1414+export type SkipReason = 'no-mapping' | 'disabled'
1515+1616+/**
1717+ * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm
1818+ * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or
1919+ * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet).
2020+ */
2121+export async function loadActiveMapping(
2222+ installationId: number,
2323+ githubRepoId: number,
2424+): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> {
2525+ const db = useDb()
2626+ const rows = await db.select().from(repoMapping).where(
2727+ and(
2828+ eq(repoMapping.installationId, installationId),
2929+ eq(repoMapping.githubRepoId, githubRepoId),
3030+ ),
3131+ ).limit(1)
3232+3333+ if (rows.length === 0) return { skip: 'no-mapping' }
3434+ const row = rows[0]!
3535+3636+ if (row.disabledAt) return { skip: 'disabled' }
3737+ if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' }
3838+3939+ return {
4040+ mapping: {
4141+ id: row.id,
4242+ githubFullName: row.githubFullName,
4343+ tangledRepoDid: row.tangledRepoDid,
4444+ knot: row.knot,
4545+ // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown`
4646+ lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record<string, string>,
4747+ },
4848+ }
4949+}
5050+5151+/** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */
5252+export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> {
5353+ const db = useDb()
5454+ await db.update(repoMapping)
5555+ .set({
5656+ lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`,
5757+ updatedAt: new Date(),
5858+ })
5959+ .where(eq(repoMapping.id, mappingId))
6060+}
6161+6262+/** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */
6363+export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> {
6464+ const db = useDb()
6565+ await db.update(repoMapping)
6666+ .set({
6767+ lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`,
6868+ updatedAt: new Date(),
6969+ })
7070+ .where(eq(repoMapping.id, mappingId))
7171+}
7272+7373+/** Mark a mapping `status='error'` so the worker stops retrying. */
7474+export async function markMappingError(mappingId: number, message: string): Promise<void> {
7575+ const db = useDb()
7676+ await db.update(repoMapping)
7777+ .set({ status: 'error', lastError: message, updatedAt: new Date() })
7878+ .where(eq(repoMapping.id, mappingId))
7979+}
8080+8181+/** Human-readable `lastError` text for a terminal knot rejection. */
8282+export function terminalRejectionMessage(err: RemoteRejectedError): string {
8383+ if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})`
8484+ if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync'
8585+ return 'knot reports repo no longer exists; stopping sync'
8686+}
8787+8888+/**
8989+ * True for knot rejections we treat as terminal: the repo is gone, our key is
9090+ * rejected, or the pack blew the size cap. Callers mark the mapping `error`
9191+ * and stop retrying; anything else re-throws for the queue's backoff.
9292+ */
9393+export function isTerminalRejection(err: unknown): err is RemoteRejectedError {
9494+ return err instanceof RemoteRejectedError
9595+ && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')
9696+}
9797+9898+/** jsonb path array element for a ref, escaping embedded quotes. */
9999+function jsonbPathElement(ref: string): string {
100100+ return `"${ref.replaceAll('"', '\\"')}"`
101101+}
+20
server/utils/repo-route.ts
···11+import type { H3Event } from 'h3'
22+import type { SynchubAccount } from './server-session'
33+import { requireSession } from './server-session'
44+55+/**
66+ * Shared preamble for `/api/repos/[id]/*` handlers: require an authenticated
77+ * session and parse the `:id` route param to a mapping id, throwing a 400 if
88+ * it isn't a finite number. Ownership is enforced downstream by scoping the
99+ * query to `session.installationId`.
1010+ */
1111+export async function requireSessionAndMappingId(
1212+ event: H3Event,
1313+): Promise<{ session: SynchubAccount, mappingId: number }> {
1414+ const session = await requireSession(event)
1515+ const mappingId = Number(getRouterParam(event, 'id'))
1616+ if (!Number.isFinite(mappingId)) {
1717+ throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' })
1818+ }
1919+ return { session, mappingId }
2020+}
+1-1
server/utils/sync-push-host.ts
···77 *
88 * The official UI does this same mapping in
99 * `appview/pages/templates/repo/empty.html`. If tangled adds more
1010- * appview-hosted knots in future this'll need updating \u2014 see PLAN.md
1010+ * appview-hosted knots in future this'll need updating; see PLAN.md
1111 * "Deferred / follow-ups".
1212 */
1313export function sshHostForKnot(knot: string): string {