Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
Summary: Detect invalid GitHub connections from live GitHub data instead of trusting cached installation rows, then show a global reconnect prompt that sends the user through a non-destructive re-auth flow before they keep using the app.

Context:
- GitHub installation state is currently treated as DB-backed cached data. `apps/web/app/api/auth/info/route.ts` only reports whether an account row or installation rows exist; it never checks whether the GitHub token or installations are still valid.
- Installation sync only happens in `apps/web/app/api/github/app/install/route.ts`, `apps/web/app/api/github/app/callback/route.ts`, and via webhook updates in `apps/web/app/api/github/webhook/route.ts`. Normal app loads do not refresh installation truth from GitHub.
- The connectors UI in `apps/web/app/settings/accounts-section.tsx` fetches `/api/github/orgs/install-status`, which depends on `getUserGitHubToken()` plus DB installation rows. If the GitHub token is invalid or GitHub returns an auth error, the component currently falls through to the empty state and looks like “zero installations” instead of surfacing “reconnect GitHub”.
- Repo-selection surfaces (`apps/web/components/repo-selector-compact.tsx`, `apps/web/components/repo-selector.tsx`, `apps/web/components/create-repo-dialog.tsx`) also trust `/api/github/installations`, which is DB-only and does not distinguish “never installed” from “previously connected but now invalidated”.
- There is already reconnect intent in the codebase: `apps/web/app/api/auth/github/unlink/route.ts` sets a `github_reconnect` cookie, and `apps/web/app/api/github/app/callback/route.ts` clears it, but `apps/web/app/api/github/app/install/route.ts` never reads it. So reconnect support is partially sketched but not actually wired.

System Impact:
- Source of truth for “is GitHub usable right now?” should shift from cached DB presence to a lightweight GitHub health check using the current user token plus a fresh installation sync.
- The DB remains the local cache for installation lists, but reconnect gating should be derived from live validation, not just row existence.
- A global reconnect state becomes available to all authenticated screens, so the app can block or interrupt flows before users hit repo picker, sandbox creation, or settings confusion.
- The reconnect action should be non-destructive: re-run OAuth/install flow first, then refresh cached installations. Do not require manual disconnect before reconnect.

Approach:
- Add a dedicated GitHub connection-health endpoint that, for authenticated users with a linked GitHub account, validates the user token, attempts a fresh installation sync, and returns one of: connected, disconnected, or reconnect_required.
- Treat these cases as reconnect_required: user token missing/refresh failed, GitHub auth failure during the health check, or installations dropping from previously-present to zero after a live sync.
- Add a dedicated reconnect entrypoint that sends the user back through the existing GitHub install/auth flow in reconnect mode without forcing them to manually disconnect first.
- Mount a global authenticated reconnect gate near the app root so the prompt appears before users start a session or navigate deep into flows.
- Tighten local surfaces so settings/repo selection show explicit reconnect messaging instead of ambiguous empty states when the health check has already determined GitHub is invalid.

Changes:
- `apps/web/app/api/github/connection-status/route.ts` - new endpoint that validates the GitHub account, performs a guarded live installation sync, and returns structured status/reason/action URL data.
- `apps/web/lib/github/installations-sync.ts` - optionally add small error classification helpers so callers can distinguish auth failures from transient GitHub/API failures.
- `apps/web/app/api/github/app/install/route.ts` - honor reconnect mode instead of blindly using the existing linked-account path; route reconnects through OAuth when needed.
- `apps/web/app/api/auth/github/reconnect/route.ts` (new) or equivalent install-route support - provide a stable non-destructive reconnect URL that preserves `next`.
- `apps/web/app/providers.tsx` - mount a global reconnect checker/gate for authenticated users, likely via a small child component under the existing SWR provider.
- `apps/web/components/github-reconnect-dialog.tsx` (new) - blocking or near-blocking reconnect prompt with primary CTA back into the reconnect flow.
- `apps/web/app/settings/accounts-section.tsx` - replace the current misleading empty/error fallthrough with explicit reconnect-aware states.
- `apps/web/components/repo-selector-compact.tsx`, `apps/web/components/repo-selector.tsx`, `apps/web/components/create-repo-dialog.tsx` - consume the same reconnect status so repo-related entry points show the right CTA instead of generic “no installations”.
- Tests for the new connection-status route and reconnect-mode install flow, plus focused UI tests where practical.
Summary: Ship a shareable public usage profile at `/[username]` backed by existing usage data, gated by an opt-in setting, with `?date=` filtering for both presets and explicit ranges, plus a dynamic OG image generated from the same derived stats.

Context:
Expand Down Expand Up @@ -41,10 +73,15 @@ Changes:
Verification:
- Run `bun run --cwd apps/web db:generate` after the schema change.
- Run `bun run ci`.
- With a healthy GitHub connection, confirm the global gate does not appear and installation/repo pickers behave as before.
- Simulate an invalid GitHub token or revoked/reduced installation state and confirm the app shows the reconnect prompt before normal use.
- Confirm the reconnect CTA returns the user to their original page and repopulates installations without requiring manual disconnect.
- Confirm settings/connections now shows a reconnect-specific state instead of a misleading zero-installations empty state.
- Confirm repo picker, create-repo flow, and sandbox/session entry points all surface the same reconnect path if reached while invalid.
- Manually verify:
- opt-in off => `/[username]` 404s
- opt-in on => `/[username]` renders public stats
- `?date=30d` and `?date=2026-01-01..2026-01-31` both filter correctly
- invalid `?date=` returns a safe fallback or 400-style handling on the public surface, depending on final implementation choice
- social metadata points at the generated image and the image reflects the selected date filter
- existing `/[username]/[repo]` onboarding still works unchanged
- existing `/[username]/[repo]` onboarding still works unchanged
41 changes: 41 additions & 0 deletions apps/web/app/api/auth/github/reconnect/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { NextRequest } from "next/server";

const routeModulePromise = import("./route");

const originalNodeEnv = process.env.NODE_ENV;

function createRequest(url: string): NextRequest {
return {
url,
nextUrl: new URL(url),
} as NextRequest;
}

describe("GET /api/auth/github/reconnect", () => {
beforeEach(() => {
Object.assign(process.env, { NODE_ENV: "test" });
});

afterEach(() => {
Object.assign(process.env, { NODE_ENV: originalNodeEnv });
});

test("sets reconnect mode and redirects into the install flow", async () => {
const { GET } = await routeModulePromise;

const response = await GET(
createRequest(
"http://localhost/api/auth/github/reconnect?next=/sessions",
),
);

expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost/api/github/app/install?next=%2Fsessions",
);

const setCookie = response.headers.get("set-cookie") ?? "";
expect(setCookie).toContain("github_reconnect=1");
});
});
30 changes: 30 additions & 0 deletions apps/web/app/api/auth/github/reconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";

function sanitizeRedirectTo(rawRedirectTo: string | null): string {
if (!rawRedirectTo) {
return "/settings/connections";
}

if (!rawRedirectTo.startsWith("/") || rawRedirectTo.startsWith("//")) {
return "/settings/connections";
}

return rawRedirectTo;
}

export async function GET(req: NextRequest): Promise<Response> {
const redirectTo = sanitizeRedirectTo(req.nextUrl.searchParams.get("next"));
const installUrl = new URL("/api/github/app/install", req.url);
installUrl.searchParams.set("next", redirectTo);

const response = NextResponse.redirect(installUrl);
response.cookies.set("github_reconnect", "1", {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 60,
sameSite: "lax",
});

return response;
}
106 changes: 106 additions & 0 deletions apps/web/app/api/github/app/install/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type { NextRequest } from "next/server";

let authSession: { user: { id: string } } | null;
let githubAccount: { externalUserId: string } | null;
let installations: Array<{ installationId: number }>;

mock.module("arctic", () => ({
generateState: () => "state-123",
}));

mock.module("@/lib/session/get-server-session", () => ({
getServerSession: async () => authSession,
}));

mock.module("@/lib/db/accounts", () => ({
getGitHubAccount: async () => githubAccount,
}));

mock.module("@/lib/db/installations", () => ({
getInstallationsByUserId: async () => installations,
}));

mock.module("@/lib/crypto", () => ({
decrypt: () => "ghu_saved",
}));

mock.module("@/lib/github/installations-sync", () => ({
syncUserInstallations: async () => installations.length,
}));

const routeModulePromise = import("./route");

const originalEnv = {
NEXT_PUBLIC_GITHUB_APP_SLUG: process.env.NEXT_PUBLIC_GITHUB_APP_SLUG,
NEXT_PUBLIC_GITHUB_CLIENT_ID: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID,
NODE_ENV: process.env.NODE_ENV,
};

function createRequest(
url: string,
cookieValues: Record<string, string> = {},
): NextRequest {
const nextUrl = new URL(url);

return {
url,
nextUrl,
cookies: {
get: (name: string) => {
const value = cookieValues[name];
return value ? { value } : undefined;
},
},
} as NextRequest;
}

describe("GET /api/github/app/install", () => {
beforeEach(() => {
authSession = { user: { id: "user-1" } };
githubAccount = { externalUserId: "123" };
installations = [{ installationId: 1 }];

Object.assign(process.env, {
NEXT_PUBLIC_GITHUB_APP_SLUG: "open-harness",
NEXT_PUBLIC_GITHUB_CLIENT_ID: "client-id",
NODE_ENV: "test",
});
});

afterEach(() => {
Object.assign(process.env, {
NEXT_PUBLIC_GITHUB_APP_SLUG: originalEnv.NEXT_PUBLIC_GITHUB_APP_SLUG,
NEXT_PUBLIC_GITHUB_CLIENT_ID: originalEnv.NEXT_PUBLIC_GITHUB_CLIENT_ID,
NODE_ENV: originalEnv.NODE_ENV,
});
});

test("forces OAuth when reconnect mode is active", async () => {
const { GET } = await routeModulePromise;

const response = await GET(
createRequest("http://localhost/api/github/app/install?next=/sessions", {
github_reconnect: "1",
}),
);

expect(response.status).toBe(307);

const location = response.headers.get("location");
expect(location).toBeTruthy();

const redirectUrl = new URL(location as string);
expect(redirectUrl.origin).toBe("https://github.com");
expect(redirectUrl.pathname).toBe("/login/oauth/authorize");
expect(redirectUrl.searchParams.get("client_id")).toBe("client-id");
expect(redirectUrl.searchParams.get("state")).toBe("state-123");
expect(redirectUrl.searchParams.get("redirect_uri")).toBe(
"http://localhost/api/github/app/callback",
);

const setCookie = response.headers.get("set-cookie") ?? "";
expect(setCookie).toContain("github_app_install_redirect_to=%2Fsessions");
expect(setCookie).toContain("github_app_install_state=state-123");
});
});
25 changes: 25 additions & 0 deletions apps/web/app/api/github/app/install/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const COOKIE_OPTIONS = {
sameSite: "lax" as const,
};

function shouldForceReconnect(req: NextRequest): boolean {
return (
req.nextUrl.searchParams.get("reconnect") === "1" ||
req.cookies.get("github_reconnect")?.value === "1"
);
}

/**
* Create a redirect response with install cookies set directly on it.
* Using NextResponse.redirect() + response.cookies.set() ensures cookies
Expand Down Expand Up @@ -82,6 +89,24 @@ export async function GET(req: NextRequest): Promise<Response> {
return redirectWithInstallCookies(installUrl, redirectTo, state);
}

if (shouldForceReconnect(req)) {
const clientId = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID;
if (clientId) {
const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
authorizeUrl.searchParams.set("client_id", clientId);
authorizeUrl.searchParams.set("state", state);
const callbackUrl = new URL("/api/github/app/callback", req.url);
authorizeUrl.searchParams.set("redirect_uri", callbackUrl.toString());
return redirectWithInstallCookies(authorizeUrl, redirectTo, state);
}

const selectTargetUrl = new URL(
`https://github.com/apps/${appSlug}/installations/select_target`,
);
selectTargetUrl.searchParams.set("state", state);
return redirectWithInstallCookies(selectTargetUrl, redirectTo, state);
}

const ghAccount = await getGitHubAccount(session.user.id);
let installations = ghAccount
? await getInstallationsByUserId(session.user.id)
Expand Down
Loading
Loading