Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"dependencies": {
"@electric-sql/client": "^1.3.1",
"@linear/sdk": "^68.1.0",
"@octokit/app": "^16.1.2",
"@octokit/rest": "^22.0.1",
"@octokit/webhooks": "^14.2.0",
Comment on lines +16 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

@octokit/app npm package latest version

💡 Result:

Latest @octokit/app on npm: 16.1.0. [1]

Sources:

  • npm package page for @octokit/app. [1]

🌐 Web query:

@octokit/rest npm package latest version

💡 Result:

The latest published version of @octokit/rest is 22.0.0. [1][2]

Sources:

  • npm package page for @octokit/rest. [1]
  • Yarn package listing (shows 22.0.0 published May 25, 2025). [2]

🌐 Web query:

@octokit/webhooks npm package latest version

💡 Result:

Latest published version (npm) of @octokit/webhooks: 13.8.1. [1]

Sources:
[1] npm package page for @octokit/webhooks.


Update Octokit dependencies to use published versions.

The specified versions do not exist on npm:

  • @octokit/app@^16.1.2 — latest published is 16.1.0
  • @octokit/rest@^22.0.1 — latest published is 22.0.0
  • @octokit/webhooks@^14.2.0 — latest published is 13.8.1

Update to versions that are actually available on npm to ensure the dependencies can be installed.

🤖 Prompt for AI Agents
In @apps/api/package.json around lines 16 - 18, The package.json lists
non-existent Octokit versions; update the dependency entries for "@octokit/app",
"@octokit/rest", and "@octokit/webhooks" to the actual published versions (set
"@octokit/app" to "16.1.0", "@octokit/rest" to "22.0.0", and "@octokit/webhooks"
to "13.8.1") and then run your package manager to regenerate the lockfile so
installs succeed.

"@sentry/nextjs": "^10.32.1",
"@superset/auth": "workspace:*",
"@superset/db": "workspace:*",
Expand Down
59 changes: 59 additions & 0 deletions apps/api/src/app/api/electric/[...path]/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { db } from "@superset/db/client";
import {
githubInstallations,
githubPullRequests,
githubRepositories,
members,
organizations,
repositories,
Expand All @@ -13,6 +16,8 @@ import { QueryBuilder } from "drizzle-orm/pg-core";
export type AllowedTable =
| "tasks"
| "repositories"
| "github_repositories"
| "github_pull_requests"
| "auth.members"
| "auth.organizations"
| "auth.users";
Expand Down Expand Up @@ -45,6 +50,60 @@ export async function buildWhereClause(
case "repositories":
return build(repositories, repositories.organizationId, organizationId);

case "github_repositories": {
// Get the GitHub installation for this organization
const installation = await db.query.githubInstallations.findFirst({
where: eq(githubInstallations.organizationId, organizationId),
columns: { id: true },
});

if (!installation) {
return { fragment: "1 = 0", params: [] };
}

return build(
githubRepositories,
githubRepositories.installationId,
installation.id,
);
}

case "github_pull_requests": {
// Get the GitHub installation for this organization
const installation = await db.query.githubInstallations.findFirst({
where: eq(githubInstallations.organizationId, organizationId),
columns: { id: true },
});

if (!installation) {
return { fragment: "1 = 0", params: [] };
}

// Get all repositories for this installation
const repos = await db.query.githubRepositories.findMany({
where: eq(githubRepositories.installationId, installation.id),
columns: { id: true },
});

if (repos.length === 0) {
return { fragment: "1 = 0", params: [] };
}

const repoIds = repos.map((r) => r.id);
const whereExpr = inArray(
sql`${sql.identifier(githubPullRequests.repositoryId.name)}`,
repoIds,
);
const qb = new QueryBuilder();
const { sql: query, params } = qb
.select()
.from(githubPullRequests)
.where(whereExpr)
.toSQL();
const fragment = query.replace(/^select .* from .* where\s+/i, "");
return { fragment, params };
}

case "auth.members":
return build(members, members.organizationId, organizationId);

Expand Down
137 changes: 137 additions & 0 deletions apps/api/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { db } from "@superset/db/client";
import { githubInstallations } from "@superset/db/schema";
import { Client } from "@upstash/qstash";
import { z } from "zod";

import { env } from "@/env";
import { githubApp } from "../octokit";

const qstash = new Client({ token: env.QSTASH_TOKEN });

const stateSchema = z.object({
organizationId: z.string().min(1),
userId: z.string().min(1),
});

/**
* Callback handler for GitHub App installation.
* GitHub redirects here after the user installs/configures the app.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const installationId = url.searchParams.get("installation_id");
const setupAction = url.searchParams.get("setup_action");
const state = url.searchParams.get("state");

if (setupAction === "cancel") {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=installation_cancelled`,
);
}

if (!installationId || !state) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=missing_params`,
);
}

const parsed = stateSchema.safeParse(
JSON.parse(Buffer.from(state, "base64url").toString("utf-8")),
);

if (!parsed.success) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=invalid_state`,
);
}
Comment on lines +38 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON.parse can throw on malformed input before Zod validation.

If the state parameter contains invalid base64 or the decoded content is not valid JSON, JSON.parse will throw an unhandled exception before Zod validation runs. This would result in a 500 error instead of a proper redirect.

Proposed fix
-	const parsed = stateSchema.safeParse(
-		JSON.parse(Buffer.from(state, "base64url").toString("utf-8")),
-	);
+	let stateData: unknown;
+	try {
+		stateData = JSON.parse(Buffer.from(state, "base64url").toString("utf-8"));
+	} catch {
+		return Response.redirect(
+			`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=invalid_state`,
+		);
+	}
+
+	const parsed = stateSchema.safeParse(stateData);
🤖 Prompt for AI Agents
In @apps/api/src/app/api/integrations/github/callback/route.ts around lines 38 -
46, The code calls JSON.parse on the decoded state which can throw before Zod
validation; update the callback handler around the Buffer.from(...).toString and
JSON.parse so you catch any errors (invalid base64 or invalid JSON) and on error
perform the same redirect used for invalid Zod parsing (redirect to
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=invalid_state`); then
continue to run stateSchema.safeParse against the parsed object (keep the parsed
variable name) when parsing succeeds.


const { organizationId, userId } = parsed.data;

try {
const octokit = await githubApp.getInstallationOctokit(
Number(installationId),
);

const installationResult = await octokit
.request("GET /app/installations/{installation_id}", {
installation_id: Number(installationId),
})
.catch((error: Error) => {
console.error("[github/callback] Failed to fetch installation:", error);
return null;
});

if (!installationResult) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=installation_fetch_failed`,
);
}

const installation = installationResult.data;

// Extract account info - account can be User or Enterprise
const account = installation.account;
const accountLogin =
account && "login" in account ? account.login : (account?.name ?? "");
const accountType =
account && "type" in account ? account.type : "Organization";

// Save the installation to our database
const [savedInstallation] = await db
.insert(githubInstallations)
.values({
organizationId,
connectedByUserId: userId,
installationId: String(installation.id),
accountLogin,
accountType,
permissions: installation.permissions as Record<string, string>,
})
.onConflictDoUpdate({
target: [githubInstallations.organizationId],
set: {
connectedByUserId: userId,
installationId: String(installation.id),
accountLogin,
accountType,
permissions: installation.permissions as Record<string, string>,
suspended: false,
suspendedAt: null, // Clear suspension if reinstalling
updatedAt: new Date(),
},
})
.returning();

if (!savedInstallation) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=save_failed`,
);
}

// Queue initial sync job
try {
await qstash.publishJSON({
url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/github/jobs/initial-sync`,
body: {
installationDbId: savedInstallation.id,
organizationId,
},
retries: 3,
});
} catch (error) {
console.error("[github/callback] Failed to queue initial sync job:", error);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?warning=sync_queue_failed`,
);
}

return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?success=github_installed`,
);
} catch (error) {
console.error("[github/callback] Unexpected error:", error);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=unexpected`,
);
}
}
56 changes: 56 additions & 0 deletions apps/api/src/app/api/integrations/github/install/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { auth } from "@superset/auth/server";
import { db } from "@superset/db/client";
import { members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";

import { env } from "@/env";

export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });

if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const url = new URL(request.url);
const organizationId = url.searchParams.get("organizationId");

if (!organizationId) {
return Response.json(
{ error: "Missing organizationId parameter" },
{ status: 400 },
);
}

const membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organizationId),
eq(members.userId, session.user.id),
),
});

if (!membership) {
return Response.json(
{ error: "User is not a member of this organization" },
{ status: 403 },
);
}

if (!env.GITHUB_APP_ID) {
return Response.json(
{ error: "GitHub App not configured" },
{ status: 500 },
);
}

const state = Buffer.from(
JSON.stringify({ organizationId, userId: session.user.id }),
).toString("base64url");

const installUrl = new URL(
`https://github.com/apps/superset-app/installations/new`,
);
installUrl.searchParams.set("state", state);

return Response.redirect(installUrl.toString());
}
Loading
Loading