···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-----
···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.
···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+})
···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+}