···6161-----END RSA PRIVATE KEY-----
6262"
63636464+# URL for installing the GitHub App. Used to redirect returning sign-ins that
6565+# have an authenticated tangled identity but no GitHub install bound yet.
6666+# Find it on your GitHub App's "Public page" link, in the form
6767+# `https://github.com/apps/<app-slug>/installations/new`.
6868+NUXT_GITHUB_APP_INSTALL_URL=https://github.com/apps/synchub-to/installations/new
6969+6470# ---------------------------------------------------------------------------
6571# Cron secret — protects the worker tick endpoint (`/api/jobs/run`) from
6672# unauthenticated callers. In prod, Vercel Cron sends this automatically;
···11+import { eq } from 'drizzle-orm'
12import { userIdentity } from '~~/server/db/schema'
23import { enqueue } from '~~/server/utils/queue'
34import { writeSession } from '~~/server/utils/server-session'
···1011 const client = await useOAuthClient()
1112 const { session, state } = await client.callback(params)
12131313- const installationId = state ? Number(state) : NaN
1414- if (!Number.isFinite(installationId)) {
1515- throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' })
1616- }
1414+ const db = useDb()
17151818- const db = useDb()
1919- await db.insert(userIdentity).values({
2020- did: session.did,
2121- handle: null, // resolved separately; we don't have it from the session blob
2222- installationId,
2323- updatedAt: new Date(),
2424- }).onConflictDoUpdate({
2525- target: userIdentity.did,
2626- set: { installationId, updatedAt: new Date() },
2727- })
1616+ // Two flows land here:
1717+ //
1818+ // - **First-time connect** (`state` set to the GitHub installation id).
1919+ // Bind the resulting `user_identity` row, publish an SSH key, kick off
2020+ // repo backfill. This is the "install GitHub App → connect tangled"
2121+ // ordering.
2222+ // - **Returning sign-in** (`state` absent). The user already has a
2323+ // `user_identity` row; we look it up by DID and write a fresh session
2424+ // so they can use the dashboard on a new device / browser.
2525+ //
2626+ // If `state` is present but invalid, or `state` is absent but no
2727+ // `user_identity` exists for the DID, we send the user to install the
2828+ // GitHub App. Trying to land them on `/dashboard` with no installation
2929+ // would just show a useless page.
3030+3131+ let installationId: number | undefined
3232+3333+ if (state) {
3434+ const parsed = Number(state)
3535+ if (!Number.isFinite(parsed)) {
3636+ throw createError({ statusCode: 400, statusMessage: 'invalid state (non-numeric installation id)' })
3737+ }
3838+ installationId = parsed
3939+4040+ await db.insert(userIdentity).values({
4141+ did: session.did,
4242+ handle: null,
4343+ installationId,
4444+ updatedAt: new Date(),
4545+ }).onConflictDoUpdate({
4646+ target: userIdentity.did,
4747+ set: { installationId, updatedAt: new Date() },
4848+ })
4949+5050+ // Generate and publish the SSH key inline: it's one ed25519 keygen + one
5151+ // PDS write, well under the function timeout, and lets us land users on
5252+ // the dashboard already enrolled. Rotation is a separate dashboard
5353+ // action that goes via the queue.
5454+ await generateAndPublishKey({ oauthSession: session, installationId })
28552929- // Generate and publish the SSH key inline: it's one ed25519 keygen + one
3030- // PDS write, well under the function timeout, and lets us land users on the
3131- // dashboard already enrolled. Rotation is a separate dashboard action that
3232- // goes via the queue.
3333- await generateAndPublishKey({
3434- oauthSession: session,
3535- installationId,
3636- })
5656+ // Backfill: enqueue a single job that walks the installation's repo list
5757+ // and fans out per-repo enrolment. Doing this in the worker (rather than
5858+ // inline here) keeps the OAuth callback fast regardless of repo count.
5959+ await enqueue('tangled.backfill-installation', { installationId, page: 1 })
6060+ }
6161+ else {
6262+ // Returning sign-in. Look up the installation we previously bound.
6363+ const rows = await db.select({ installationId: userIdentity.installationId })
6464+ .from(userIdentity)
6565+ .where(eq(userIdentity.did, session.did))
6666+ .limit(1)
37673838- // Backfill: enqueue a single job that walks the installation's repo list
3939- // and fans out per-repo enrolment. Doing this in the worker (rather than
4040- // inline here) keeps the OAuth callback fast regardless of repo count, and
4141- // gives us proper retry semantics if pagination doesn't finish in one
4242- // worker tick.
4343- await enqueue('tangled.backfill-installation', { installationId, page: 1 })
6868+ if (rows.length === 0 || rows[0]!.installationId === null) {
6969+ // Authenticated tangled identity, but no GitHub install bound. Send
7070+ // them to install the App. We deliberately do NOT write a session
7171+ // yet — the dashboard needs an installation to be useful.
7272+ const appInstallUrl = process.env.NUXT_GITHUB_APP_INSTALL_URL
7373+ ?? 'https://github.com/apps/synchub-to/installations/new'
7474+ await sendRedirect(event, appInstallUrl, 302)
7575+ return
7676+ }
7777+ installationId = rows[0]!.installationId
7878+ }
44794545- // Sealed cookie session for the dashboard. Handle resolution is deferred:
4646- // for v1 we surface the DID and the GitHub install's `accountLogin`, which
4747- // we already have. A handle resolver can populate `session.handle` later.
8080+ // Sealed cookie session for the dashboard. Handle resolution is deferred.
4881 await writeSession(event, { did: session.did, installationId })
49825083 await sendRedirect(event, '/dashboard', 302)
+17-8
server/api/atproto/login.get.ts
···66 if (typeof handleRaw !== 'string' || !handleRaw.trim()) {
77 throw createError({ statusCode: 400, statusMessage: 'handle is required' })
88 }
99- if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) {
1010- throw createError({ statusCode: 400, statusMessage: 'installationId is required' })
99+1010+ // `installationId` is *optional*:
1111+ // - First-time install → connect flow passes it (from the GitHub App
1212+ // install settings) so we can bind the resulting `user_identity` row.
1313+ // - Returning user sign-in (e.g. on a new device) omits it; the callback
1414+ // looks up the existing `user_identity` row by DID.
1515+ let installationId: string | undefined
1616+ if (typeof installationIdRaw === 'string' && installationIdRaw !== '') {
1717+ if (!/^\d+$/.test(installationIdRaw)) {
1818+ throw createError({ statusCode: 400, statusMessage: 'installationId must be numeric' })
1919+ }
2020+ installationId = installationIdRaw
1121 }
12221323 const handle = handleRaw.trim()
1414- const installationId = installationIdRaw
1515-1624 const client = await useOAuthClient()
17251818- // Round-trip the installation id via OAuth `state`. The library wraps and
1919- // signs `state` itself (PKCE + state CSRF protection are handled internally),
2020- // so this is safe to use as an opaque link key.
2626+ // Round-trip the installation id via OAuth `state` when we have one. The
2727+ // library wraps and signs `state` itself (PKCE + state CSRF protection are
2828+ // handled internally), so this is safe to use as an opaque link key. When
2929+ // absent, the callback resolves the installation via the returned DID.
2130 const url = await client.authorize(handle, {
2222- state: installationId,
3131+ ...(installationId ? { state: installationId } : {}),
2332 scope: SYNCHUB_OAUTH_SCOPE,
2433 })
2534