···5353# - The webhook secret you set during creation.
5454# - A generated private key (.pem). On Vercel, store with literal "\n" in
5555# place of newlines; locally, keep the real newlines.
5656+# - The Client ID and a generated client secret ("Client secrets" section).
5757+# These drive the user-to-server OAuth that proves a connecting user
5858+# actually administers the installation they're binding a tangled handle
5959+# to. Distinct from the private key above. Required for the /connect flow.
5660# ---------------------------------------------------------------------------
5761NUXT_GITHUB_APP_ID=<numeric app id>
5862NUXT_GITHUB_WEBHOOK_SECRET=<webhook secret>
6363+NUXT_GITHUB_APP_CLIENT_ID=<github app client id, e.g. Iv1.abc123>
6464+NUXT_GITHUB_APP_CLIENT_SECRET=<github app client secret>
5965NUXT_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
6066...
6167-----END RSA PRIVATE KEY-----
+12-4
README.md
···4545Neon dashboard and a [new GitHub App](https://github.com/settings/apps/new).
4646The App needs `contents:read` and `metadata:read` permissions plus the `push`,
4747`create`, `delete`, and `repository` events, with its webhook pointed at your
4848-Smee URL.
4848+Smee URL. Set the **Setup URL** to `<origin>/connect` (tick *Redirect on
4949+update*) and the **Callback URL** to `<origin>/api/github/oauth/callback`; the
5050+latter drives the user-OAuth that verifies a connecting user administers the
5151+installation before any handle is bound. Copy the App's **Client ID** and a
5252+generated **client secret** into `NUXT_GITHUB_APP_CLIENT_ID` /
5353+`NUXT_GITHUB_APP_CLIENT_SECRET`.
49545055In separate terminals, proxy webhooks and drain the job queue:
5156···65702. Import the repo into Vercel (the Nuxt preset is auto-detected) and set every
6671 variable from `.env.example` under **Settings > Environment Variables**.
6772 Mark the secrets (`NUXT_DATABASE_URL`, `NUXT_GITHUB_APP_PRIVATE_KEY`,
6868- `NUXT_ATPROTO_PRIVATE_JWK`, `NUXT_ENCRYPTION_KEY`, `NUXT_SESSION_PASSWORD`,
7373+ `NUXT_GITHUB_APP_CLIENT_SECRET`, `NUXT_ATPROTO_PRIVATE_JWK`,
7474+ `NUXT_ENCRYPTION_KEY`, `NUXT_SESSION_PASSWORD`,
6975 `NUXT_GITHUB_WEBHOOK_SECRET`, `NUXT_CRON_SECRET`) as **Sensitive**.
7070-3. Set `NUXT_PUBLIC_URL` to your real origin and point the GitHub App webhook at
7171- `https://<your-domain>/api/github/webhook`.
7676+3. Set `NUXT_PUBLIC_URL` to your real origin, point the GitHub App webhook at
7777+ `https://<your-domain>/api/github/webhook`, and set the App's Setup +
7878+ Callback URLs to `https://<your-domain>/connect` and
7979+ `https://<your-domain>/api/github/oauth/callback`.
72804. Deploy.
73817482The worker runs on a Vercel Cron (declared in `nuxt.config.ts`, so no
···11import { and, eq, ne } from 'drizzle-orm'
22import { userIdentity } from '#server/db/schema'
33import { enqueue } from '#server/utils/queue'
44+import { clearInstallOwnership, hasVerifiedInstall } from '#server/utils/install-ownership'
45import { resolveHandle } from '#server/utils/resolve-handle'
56import { addAccount } from '#server/utils/server-session'
67import { generateAndPublishKey, revokeKeyForInstallationDid } from '#server/utils/tangled-pubkey'
···3940 }
4041 installationId = parsed
41424343+ // Bind-guard: the connecting user must have proven (via GitHub user-OAuth
4444+ // in the /connect flow) that they administer this installation. Without
4545+ // this check, anyone who completes a tangled OAuth carrying a victim's
4646+ // installation id in `state` could hijack the mirror. The proof is a
4747+ // sealed cookie set by /api/github/oauth/callback.
4848+ if (!(await hasVerifiedInstall(event, installationId))) {
4949+ throw createError({
5050+ statusCode: 403,
5151+ statusMessage: 'installation ownership not verified; start from the connect page',
5252+ })
5353+ }
5454+4255 // One installation maps to exactly one DID. If another DID is currently
4356 // bound to this installation, this connect displaces it: revoke that DID's
4457 // now-dead SSH key (PDS record + local row) and null its installationId so
···87100 // and fans out per-repo enrolment. Doing this in the worker (rather than
88101 // inline here) keeps the OAuth callback fast regardless of repo count.
89102 await enqueue('tangled.backfill-installation', { installationId, page: 1 })
103103+104104+ // Consume the ownership proof so it can't be replayed for another bind.
105105+ await clearInstallOwnership(event)
90106 }
91107 else {
92108 // Returning sign-in. Look up the installation we previously bound.
+21
server/api/connect/info.get.ts
···11+import { installationAccountLogin } from '#server/utils/github-app'
22+33+export interface ConnectInfo {
44+ installationId: number
55+ login: string | null
66+}
77+88+/**
99+ * Public lookup of an installation's account login for the connect page.
1010+ * Returns only the login, which the App can already read; it is not a secret
1111+ * and reveals nothing the install screen didn't. The actual ownership gate is
1212+ * the GitHub user-OAuth step, not this endpoint.
1313+ */
1414+export default defineEventHandler(async (event): Promise<ConnectInfo> => {
1515+ const raw = getQuery(event).installationId
1616+ if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
1717+ throw createError({ statusCode: 400, statusMessage: 'installationId is required and must be numeric' })
1818+ }
1919+ const installationId = Number(raw)
2020+ return { installationId, login: await installationAccountLogin(installationId) }
2121+})
+55
server/api/github/oauth/callback.get.ts
···11+import { installationAccountLogin, userAdministersInstallation, userOctokitFromCode } from '#server/utils/github-app'
22+import { markInstallOwned } from '#server/utils/install-ownership'
33+import { sessionConfig } from '#server/utils/server-session'
44+55+interface OAuthFlowData {
66+ state: string
77+ installationId: number
88+}
99+1010+/**
1111+ * GitHub user-OAuth callback. Verifies the `state` against the sealed flow
1212+ * cookie, exchanges the code for a user token, and confirms the user
1313+ * administers the installation. On success, marks the install owned (a sealed
1414+ * cookie the atproto callback checks before binding) and sends the user into
1515+ * the tangled connect flow.
1616+ */
1717+export default defineEventHandler(async event => {
1818+ const query = getQuery(event)
1919+ const code = typeof query.code === 'string' ? query.code : null
2020+ const state = typeof query.state === 'string' ? query.state : null
2121+ if (!code || !state) {
2222+ throw createError({ statusCode: 400, statusMessage: 'missing code or state' })
2323+ }
2424+2525+ const flow = await useSession<OAuthFlowData>(event, {
2626+ ...sessionConfig(),
2727+ name: 'synchub-gh-oauth',
2828+ maxAge: 10 * 60,
2929+ })
3030+ const expectedState = flow.data.state
3131+ const installationId = flow.data.installationId
3232+ await flow.clear()
3333+3434+ if (!expectedState || expectedState !== state || typeof installationId !== 'number') {
3535+ throw createError({ statusCode: 400, statusMessage: 'invalid or expired oauth state' })
3636+ }
3737+3838+ const userOctokit = await userOctokitFromCode(code)
3939+ const administers = await userAdministersInstallation(userOctokit, installationId)
4040+ if (!administers) {
4141+ throw createError({
4242+ statusCode: 403,
4343+ statusMessage: 'your GitHub account does not administer this installation',
4444+ })
4545+ }
4646+4747+ await markInstallOwned(event, installationId)
4848+4949+ // Ownership proven. Hand off to the connect page, which now lets the user
5050+ // pick the tangled handle to bind. Carry the account login for display.
5151+ const login = await installationAccountLogin(installationId)
5252+ const params = new URLSearchParams({ installation_id: String(installationId), verified: '1' })
5353+ if (login) params.set('login', login)
5454+ await sendRedirect(event, `/connect?${params.toString()}`, 302)
5555+})
+34
server/api/github/oauth/start.get.ts
···11+import { randomBytes } from 'node:crypto'
22+import { githubOAuthUrl } from '#server/utils/github-app'
33+import { sessionConfig } from '#server/utils/server-session'
44+55+interface OAuthFlowData {
66+ state: string
77+ installationId: number
88+}
99+1010+/**
1111+ * Begin GitHub user-to-server OAuth to prove the connecting user administers
1212+ * the installation they're about to bind a tangled handle to. The random
1313+ * `state` is sealed into a short-lived cookie and verified in the callback.
1414+ */
1515+export default defineEventHandler(async event => {
1616+ const query = getQuery(event)
1717+ const raw = query.installationId
1818+ if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
1919+ throw createError({ statusCode: 400, statusMessage: 'installationId is required and must be numeric' })
2020+ }
2121+ const installationId = Number(raw)
2222+2323+ const state = randomBytes(16).toString('base64url')
2424+ const flow = await useSession<OAuthFlowData>(event, {
2525+ ...sessionConfig(),
2626+ name: 'synchub-gh-oauth',
2727+ maxAge: 10 * 60,
2828+ })
2929+ await flow.update({ state, installationId })
3030+3131+ const { url } = useRuntimeConfig().public
3232+ const redirectUri = `${url.replace(/\/$/, '')}/api/github/oauth/callback`
3333+ await sendRedirect(event, githubOAuthUrl({ state, redirectUri }), 302)
3434+})
+72
server/utils/github-app.ts
···99 if (!appId || !privateKey) {
1010 throw new Error('NUXT_GITHUB_APP_ID and NUXT_GITHUB_APP_PRIVATE_KEY must be set')
1111 }
1212+ // The OAuth client id/secret are optional at construction time so the
1313+ // webhook + sync paths (server-to-server only) keep working without them;
1414+ // requireOAuthApp() throws explicitly when the /connect flow needs them.
1515+ const clientId = process.env.NUXT_GITHUB_APP_CLIENT_ID
1616+ const clientSecret = process.env.NUXT_GITHUB_APP_CLIENT_SECRET
1217 cachedApp = new App({
1318 appId,
1919+ ...(clientId && clientSecret ? { oauth: { clientId, clientSecret } } : {}),
1420 // Vercel env vars escape newlines; restore them so PEM parsing works.
1521 privateKey: privateKey.replaceAll('\\n', '\n'),
1622 })
···1824}
19252026export type InstallationOctokit = Awaited<ReturnType<App['getInstallationOctokit']>>
2727+export type UserOctokit = Awaited<ReturnType<App['oauth']['getUserOctokit']>>
21282229/** Get an Octokit pre-authed for a specific GitHub App installation. */
2330export async function installationOctokit(installationId: number): Promise<InstallationOctokit> {
2431 const app = useApp()
2532 return app.getInstallationOctokit(installationId)
3333+}
3434+3535+function requireOAuthApp(): App {
3636+ if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) {
3737+ throw createError({
3838+ statusCode: 500,
3939+ statusMessage: 'GitHub user-OAuth is not configured (set NUXT_GITHUB_APP_CLIENT_ID and NUXT_GITHUB_APP_CLIENT_SECRET)',
4040+ })
4141+ }
4242+ return useApp()
4343+}
4444+4545+/**
4646+ * Build the GitHub user-to-server OAuth authorize URL. `state` is round-tripped
4747+ * back to the callback for CSRF protection; the callback verifies it against a
4848+ * sealed cookie.
4949+ */
5050+export function githubOAuthUrl(opts: { state: string, redirectUri: string }): string {
5151+ const app = requireOAuthApp()
5252+ const { url } = app.oauth.getWebFlowAuthorizationUrl({
5353+ state: opts.state,
5454+ redirectUrl: opts.redirectUri,
5555+ allowSignup: false,
5656+ })
5757+ return url
5858+}
5959+6060+/**
6161+ * Exchange an OAuth `code` for a user access token and return an Octokit
6262+ * authenticated as that user.
6363+ */
6464+export async function userOctokitFromCode(code: string): Promise<UserOctokit> {
6565+ const app = requireOAuthApp()
6666+ return app.oauth.getUserOctokit({ code })
6767+}
6868+6969+/**
7070+ * True if the authenticated user administers `installationId`. The
7171+ * user-to-server `GET /user/installations` endpoint only returns installations
7272+ * the user can administer, so membership in the list is the ownership proof.
7373+ */
7474+export async function userAdministersInstallation(
7575+ userOctokit: UserOctokit,
7676+ installationId: number,
7777+): Promise<boolean> {
7878+ // A user with >100 app installations is implausible here, so one page is
7979+ // enough; `/user/installations` only lists installs the user administers.
8080+ const { data } = await userOctokit.request('GET /user/installations', { per_page: 100 })
8181+ return data.installations.some(install => install.id === installationId)
8282+}
8383+8484+/** Resolve an installation's account login (for display on the connect page). */
8585+export async function installationAccountLogin(installationId: number): Promise<string | null> {
8686+ const app = useApp()
8787+ try {
8888+ const { data } = await app.octokit.request('GET /app/installations/{installation_id}', {
8989+ installation_id: installationId,
9090+ })
9191+ const account = data.account
9292+ if (account && 'login' in account) return account.login
9393+ return null
9494+ }
9595+ catch {
9696+ return null
9797+ }
2698}
279928100/** Test hook. */
+48
server/utils/install-ownership.ts
···11+import type { H3Event } from 'h3'
22+import { sessionConfig } from './server-session'
33+44+/**
55+ * Short-lived sealed cookie proving the current browser completed GitHub
66+ * user-OAuth and was confirmed as an administrator of a specific installation.
77+ *
88+ * The atproto callback checks this before binding a DID to an installation, so
99+ * a connecting user can't claim an installation they don't actually control by
1010+ * crafting `/connect?installation_id=<victim>`. Reuses the session password
1111+ * for sealing; a distinct cookie name and a 15-minute TTL keep it scoped to a
1212+ * single connect flow.
1313+ */
1414+interface InstallOwnershipData {
1515+ installationId: number
1616+ verifiedAt: number
1717+}
1818+1919+const COOKIE_NAME = 'synchub-install-ownership'
2020+const TTL_SECONDS = 15 * 60
2121+2222+function ownershipConfig() {
2323+ return { ...sessionConfig(), name: COOKIE_NAME, maxAge: TTL_SECONDS }
2424+}
2525+2626+export async function markInstallOwned(event: H3Event, installationId: number): Promise<void> {
2727+ const session = await useSession<InstallOwnershipData>(event, ownershipConfig())
2828+ await session.update({ installationId, verifiedAt: Date.now() })
2929+}
3030+3131+/**
3232+ * Return true if the current browser proved ownership of `installationId`
3333+ * within the TTL. Does not clear the cookie; the caller clears it after a
3434+ * successful bind so it can't be replayed.
3535+ */
3636+export async function hasVerifiedInstall(event: H3Event, installationId: number): Promise<boolean> {
3737+ const session = await useSession<InstallOwnershipData>(event, ownershipConfig())
3838+ const { installationId: owned, verifiedAt } = session.data
3939+ if (typeof owned !== 'number' || typeof verifiedAt !== 'number') return false
4040+ if (owned !== installationId) return false
4141+ if (Date.now() - verifiedAt > TTL_SECONDS * 1000) return false
4242+ return true
4343+}
4444+4545+export async function clearInstallOwnership(event: H3Event): Promise<void> {
4646+ const session = await useSession<InstallOwnershipData>(event, ownershipConfig())
4747+ await session.clear()
4848+}
+26
test/unit/github-ownership.spec.ts
···11+import { describe, expect, it, vi } from 'vitest'
22+import { userAdministersInstallation } from '../../server/utils/github-app'
33+import type { UserOctokit } from '../../server/utils/github-app'
44+55+function octokitReturning(ids: number[]): UserOctokit {
66+ const request = vi.fn(async () => ({ data: { installations: ids.map(id => ({ id })) } }))
77+ // eslint-disable-next-line ts/no-unsafe-type-assertion -- only `request` is exercised
88+ return { request } as unknown as UserOctokit
99+}
1010+1111+describe('userAdministersInstallation', () => {
1212+ it('returns true when the installation is in the user list', async () => {
1313+ const octokit = octokitReturning([10, 137556633, 42])
1414+ expect(await userAdministersInstallation(octokit, 137556633)).toBe(true)
1515+ })
1616+1717+ it('returns false when the installation is absent', async () => {
1818+ const octokit = octokitReturning([10, 42])
1919+ expect(await userAdministersInstallation(octokit, 137556633)).toBe(false)
2020+ })
2121+2222+ it('returns false for an empty installation list', async () => {
2323+ const octokit = octokitReturning([])
2424+ expect(await userAdministersInstallation(octokit, 1)).toBe(false)
2525+ })
2626+})
+2-2
test/unit/tangled-pubkey.spec.ts
···347347 await revokeKeyForInstallationDid(1, 'did:plc:1')
348348349349 expect(restoreMock).toHaveBeenCalledWith('did:plc:1')
350350- const del = deleteRecordMock.mock.calls[0]![0]
350350+ const del = deleteRecordMock.mock.calls[0][0]
351351 expect(del.repo).toBe('did:plc:1')
352352 expect(del.collection).toBe('sh.tangled.publicKey')
353353 expect(del.rkey).toBe('rkey-1')
···363363 const db = useDb()
364364 const rows = await db.select().from(sshKey)
365365 expect(rows).toHaveLength(1)
366366- expect(rows[0]!.did).toBe('did:plc:2')
366366+ expect(rows[0].did).toBe('did:plc:2')
367367 })
368368369369 it('no-ops when no key exists for the pair', async () => {
+1-1
test/utils/git-wire.ts
···7171 const url = typeof input === 'string' ? input : input.toString()
7272 const match = url.match(/github\.com\/(.+?)\.git\/(info\/refs|git-upload-pack)/)
7373 if (!match) throw new Error(`fakeGithubFetch: unexpected url ${url}`)
7474- const repoPath = repos.get(match[1]!)
7474+ const repoPath = repos.get(match[1])
7575 if (!repoPath) return new Response(null, { status: 404, statusText: 'Not Found' })
76767777 if (match[2] === 'info/refs') {