COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
ONBOOK

GitHub App Reinstallation Callback Issue

When a user installs the GitHub App but our callback fails (network error, state expiry, etc.), the installation exists on GitHub but not in our database. If the user tries to connect again:

2025-01-07 // RAW LEARNING CAPTURE
PROJECTONBOOK

Date: 2025-01-07 Status: Documented for post-v1 Files: apps/web/src/app/api/github/callback/route.ts, packages/trpc/src/router/github/index.ts

Problem

When a user installs the GitHub App but our callback fails (network error, state expiry, etc.), the installation exists on GitHub but not in our database. If the user tries to connect again:

  1. They're sent to https://github.com/apps/{slug}/installations/new?state={state}
  2. GitHub sees app is already installed → shows "Configure installation" page
  3. No redirect occurs unless user actually changes something (adds/removes repos)
  4. User is stuck on GitHub with no way back

This is a known GitHub limitation - there's no "always redirect" parameter.

Solutions

Set up the installation webhook so we receive installation events regardless of callback success.

GitHub App Settings → Webhooks:

  • Subscribe to: installation events
  • Events: created, deleted, suspend, unsuspend

Implementation:

// apps/web/src/app/api/github/webhook/route.ts
import { Webhooks } from '@octokit/webhooks';
import { db } from '@onbook/db';
import { githubInstallations } from '@onbook/db/schema';
import { eq } from 'drizzle-orm';

const webhooks = new Webhooks({
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
});

webhooks.on('installation.created', async ({ payload }) => {
  const { installation, sender } = payload;

  // Store as "pending" - no org association yet
  // User will claim it when they complete onboarding
  await db.insert(githubInstallations).values({
    installationId: installation.id,
    accountId: installation.account.id,
    accountLogin: installation.account.login,
    accountType: installation.account.type,
    accountAvatarUrl: installation.account.avatar_url,
    organizationId: null, // Pending - will be claimed
    userId: null,
    installedBy: sender.login, // Track who installed
  }).onConflictDoUpdate({
    target: githubInstallations.installationId,
    set: {
      accountLogin: installation.account.login,
      accountAvatarUrl: installation.account.avatar_url,
      updatedAt: new Date(),
      deletedAt: null,
    },
  });
});

webhooks.on('installation.deleted', async ({ payload }) => {
  await db.update(githubInstallations)
    .set({ deletedAt: new Date() })
    .where(eq(githubInstallations.installationId, payload.installation.id));
});

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('x-hub-signature-256')!;

  await webhooks.verifyAndReceive({
    id: request.headers.get('x-github-delivery')!,
    name: request.headers.get('x-github-event') as any,
    signature,
    payload: body,
  });

  return new Response('OK');
}

Schema change needed:

// Add to githubInstallations table
installedBy: varchar('installed_by', { length: 255 }), // GitHub username who installed
// Make organizationId nullable for pending installations
organizationId: varchar('organization_id', { length: 255 }), // Remove .notNull()

Claiming flow:

// New endpoint: github.claimInstallation
claimInstallation: protectedProcedure
  .input(z.object({ installationId: z.number() }))
  .mutation(async ({ ctx, input }) => {
    // Verify installation exists and is unclaimed
    const installation = await db.query.githubInstallations.findFirst({
      where: and(
        eq(githubInstallations.installationId, input.installationId),
        isNull(githubInstallations.organizationId),
      ),
    });

    if (!installation) {
      throw new TRPCError({ code: 'NOT_FOUND' });
    }

    // Claim it for current org
    await db.update(githubInstallations)
      .set({
        organizationId: ctx.auth.session.activeOrganizationId,
        userId: ctx.auth.user.id,
      })
      .where(eq(githubInstallations.installationId, input.installationId));
  }),

Solution 2: Direct API Lookup (Simpler, Good Fallback)

GitHub provides direct lookup endpoints - no need to paginate all installations:

EndpointPurpose
GET /orgs/{org}/installationLook up by GitHub org name
GET /users/{username}/installationLook up by GitHub username

Returns installation details or 404. Requires JWT auth (as the App).

Implementation:

// packages/trpc/src/router/github/index.ts

/**
 * Look up installation by GitHub org/username
 * Used when callback failed but user already installed
 */
syncInstallation: protectedProcedure
  .input(z.object({
    accountLogin: z.string(),
    accountType: z.enum(['Organization', 'User']),
  }))
  .mutation(async ({ ctx, input }) => {
    const octokit = await getAppOctokit();

    try {
      // Direct lookup - no pagination needed
      const endpoint = input.accountType === 'Organization'
        ? octokit.apps.getOrgInstallation({ org: input.accountLogin })
        : octokit.apps.getUserInstallation({ username: input.accountLogin });

      const { data } = await endpoint;

      // Save/update installation
      await db.insert(githubInstallations).values({
        installationId: data.id,
        accountId: data.account?.id ?? 0,
        accountLogin: data.account?.login ?? input.accountLogin,
        accountType: input.accountType,
        accountAvatarUrl: data.account?.avatar_url ?? null,
        organizationId: ctx.auth.session.activeOrganizationId,
        userId: ctx.auth.user.id,
      }).onConflictDoUpdate({
        target: githubInstallations.installationId,
        set: {
          organizationId: ctx.auth.session.activeOrganizationId,
          userId: ctx.auth.user.id,
          updatedAt: new Date(),
          deletedAt: null,
        },
      });

      return { success: true, installationId: data.id };
    } catch (error) {
      if ((error as any).status === 404) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'GitHub App not installed on this account',
        });
      }
      throw error;
    }
  }),

UI for fallback:

// In ConnectStep.tsx - add "Already connected?" section

const [showSync, setShowSync] = useState(false);
const [accountLogin, setAccountLogin] = useState('');
const syncMutation = trpc.github.syncInstallation.useMutation();

// Show after user returns from GitHub without callback
{showSync && (
  <div className="mt-4 p-4 border rounded">
    <p className="text-sm text-muted-foreground mb-2">
      Already installed the app? Enter your GitHub organization or username:
    </p>
    <div className="flex gap-2">
      <Input
        value={accountLogin}
        onChange={(e) => setAccountLogin(e.target.value)}
        placeholder="acme-corp or username"
      />
      <Button
        onClick={() => syncMutation.mutate({
          accountLogin,
          accountType: 'Organization', // Could add toggle
        })}
        disabled={syncMutation.isPending}
      >
        Sync
      </Button>
    </div>
  </div>
)}

Solution 3: Enable "Redirect on Update"

In GitHub App settings, enable "Redirect on update" checkbox. This redirects users to your Setup URL after they modify the installation (add/remove repos).

Limitation: Doesn't help if user makes no changes - they still get stuck.

Location: GitHub → Settings → Developer settings → GitHub Apps → [Your App] → General → Post installation


Recommendation for Post-v1

  1. Implement webhook solution - most robust, catches all installations
  2. Add direct lookup as fallback UI - helps users who are stuck
  3. Enable "Redirect on update" - free improvement, enable now

References

LOG.ENTRY_END
ref:onbook
RAW