From c4a90369f9d22a04a30a15c264526a5cc66d2324 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 11 Jun 2026 17:40:11 +0100 Subject: [PATCH] feat: add sign-in flow for returning visitors --- .env.example | 6 ++ app/pages/index.vue | 114 ++++++++++++++++++++++++++++- server/api/atproto/callback.get.ts | 99 ++++++++++++++++--------- server/api/atproto/login.get.ts | 25 +++++-- 4 files changed, 201 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index 7b27415..c76aa8e 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,12 @@ NUXT_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- -----END RSA PRIVATE KEY----- " +# URL for installing the GitHub App. Used to redirect returning sign-ins that +# have an authenticated tangled identity but no GitHub install bound yet. +# Find it on your GitHub App's "Public page" link, in the form +# `https://github.com/apps//installations/new`. +NUXT_GITHUB_APP_INSTALL_URL=https://github.com/apps/synchub-to/installations/new + # --------------------------------------------------------------------------- # Cron secret — protects the worker tick endpoint (`/api/jobs/run`) from # unauthenticated callers. In prod, Vercel Cron sends this automatically; diff --git a/app/pages/index.vue b/app/pages/index.vue index 4b4375f..ccc33bc 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,6 +1,116 @@ + + + + diff --git a/server/api/atproto/callback.get.ts b/server/api/atproto/callback.get.ts index 0b05e61..e35a049 100644 --- a/server/api/atproto/callback.get.ts +++ b/server/api/atproto/callback.get.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm' import { userIdentity } from '~~/server/db/schema' import { enqueue } from '~~/server/utils/queue' import { writeSession } from '~~/server/utils/server-session' @@ -10,41 +11,73 @@ export default defineEventHandler(async event => { const client = await useOAuthClient() const { session, state } = await client.callback(params) - const installationId = state ? Number(state) : NaN - if (!Number.isFinite(installationId)) { - throw createError({ statusCode: 400, statusMessage: 'invalid state (missing installation id)' }) + const db = useDb() + + // Two flows land here: + // + // - **First-time connect** (`state` set to the GitHub installation id). + // Bind the resulting `user_identity` row, publish an SSH key, kick off + // repo backfill. This is the "install GitHub App → connect tangled" + // ordering. + // - **Returning sign-in** (`state` absent). The user already has a + // `user_identity` row; we look it up by DID and write a fresh session + // so they can use the dashboard on a new device / browser. + // + // If `state` is present but invalid, or `state` is absent but no + // `user_identity` exists for the DID, we send the user to install the + // GitHub App. Trying to land them on `/dashboard` with no installation + // would just show a useless page. + + let installationId: number | undefined + + if (state) { + const parsed = Number(state) + if (!Number.isFinite(parsed)) { + throw createError({ statusCode: 400, statusMessage: 'invalid state (non-numeric installation id)' }) + } + installationId = parsed + + await db.insert(userIdentity).values({ + did: session.did, + handle: null, + installationId, + updatedAt: new Date(), + }).onConflictDoUpdate({ + target: userIdentity.did, + set: { installationId, updatedAt: new Date() }, + }) + + // Generate and publish the SSH key inline: it's one ed25519 keygen + one + // PDS write, well under the function timeout, and lets us land users on + // the dashboard already enrolled. Rotation is a separate dashboard + // action that goes via the queue. + await generateAndPublishKey({ oauthSession: session, installationId }) + + // Backfill: enqueue a single job that walks the installation's repo list + // and fans out per-repo enrolment. Doing this in the worker (rather than + // inline here) keeps the OAuth callback fast regardless of repo count. + await enqueue('tangled.backfill-installation', { installationId, page: 1 }) } + else { + // Returning sign-in. Look up the installation we previously bound. + const rows = await db.select({ installationId: userIdentity.installationId }) + .from(userIdentity) + .where(eq(userIdentity.did, session.did)) + .limit(1) - const db = useDb() - await db.insert(userIdentity).values({ - did: session.did, - handle: null, // resolved separately; we don't have it from the session blob - installationId, - updatedAt: new Date(), - }).onConflictDoUpdate({ - target: userIdentity.did, - set: { installationId, updatedAt: new Date() }, - }) - - // Generate and publish the SSH key inline: it's one ed25519 keygen + one - // PDS write, well under the function timeout, and lets us land users on the - // dashboard already enrolled. Rotation is a separate dashboard action that - // goes via the queue. - await generateAndPublishKey({ - oauthSession: session, - installationId, - }) - - // Backfill: enqueue a single job that walks the installation's repo list - // and fans out per-repo enrolment. Doing this in the worker (rather than - // inline here) keeps the OAuth callback fast regardless of repo count, and - // gives us proper retry semantics if pagination doesn't finish in one - // worker tick. - await enqueue('tangled.backfill-installation', { installationId, page: 1 }) - - // Sealed cookie session for the dashboard. Handle resolution is deferred: - // for v1 we surface the DID and the GitHub install's `accountLogin`, which - // we already have. A handle resolver can populate `session.handle` later. + if (rows.length === 0 || rows[0]!.installationId === null) { + // Authenticated tangled identity, but no GitHub install bound. Send + // them to install the App. We deliberately do NOT write a session + // yet — the dashboard needs an installation to be useful. + const appInstallUrl = process.env.NUXT_GITHUB_APP_INSTALL_URL + ?? 'https://github.com/apps/synchub-to/installations/new' + await sendRedirect(event, appInstallUrl, 302) + return + } + installationId = rows[0]!.installationId + } + + // Sealed cookie session for the dashboard. Handle resolution is deferred. await writeSession(event, { did: session.did, installationId }) await sendRedirect(event, '/dashboard', 302) diff --git a/server/api/atproto/login.get.ts b/server/api/atproto/login.get.ts index aa4d1e1..afa69a3 100644 --- a/server/api/atproto/login.get.ts +++ b/server/api/atproto/login.get.ts @@ -6,20 +6,29 @@ export default defineEventHandler(async event => { if (typeof handleRaw !== 'string' || !handleRaw.trim()) { throw createError({ statusCode: 400, statusMessage: 'handle is required' }) } - if (typeof installationIdRaw !== 'string' || !/^\d+$/.test(installationIdRaw)) { - throw createError({ statusCode: 400, statusMessage: 'installationId is required' }) + + // `installationId` is *optional*: + // - First-time install → connect flow passes it (from the GitHub App + // install settings) so we can bind the resulting `user_identity` row. + // - Returning user sign-in (e.g. on a new device) omits it; the callback + // looks up the existing `user_identity` row by DID. + let installationId: string | undefined + if (typeof installationIdRaw === 'string' && installationIdRaw !== '') { + if (!/^\d+$/.test(installationIdRaw)) { + throw createError({ statusCode: 400, statusMessage: 'installationId must be numeric' }) + } + installationId = installationIdRaw } const handle = handleRaw.trim() - const installationId = installationIdRaw - const client = await useOAuthClient() - // Round-trip the installation id via OAuth `state`. The library wraps and - // signs `state` itself (PKCE + state CSRF protection are handled internally), - // so this is safe to use as an opaque link key. + // Round-trip the installation id via OAuth `state` when we have one. The + // library wraps and signs `state` itself (PKCE + state CSRF protection are + // handled internally), so this is safe to use as an opaque link key. When + // absent, the callback resolves the installation via the returned DID. const url = await client.authorize(handle, { - state: installationId, + ...(installationId ? { state: installationId } : {}), scope: SYNCHUB_OAUTH_SCOPE, }) -- 2.51.2