diff --git a/apps/mesh/migrations/063-github-credentials.ts b/apps/mesh/migrations/063-github-credentials.ts new file mode 100644 index 0000000000..aaf35df1f0 --- /dev/null +++ b/apps/mesh/migrations/063-github-credentials.ts @@ -0,0 +1,27 @@ +/** + * GitHub Credentials Migration + * + * Stores GitHub OAuth access tokens per user, encrypted at rest via the vault. + * Scoped to the user (not the org) since a GitHub OAuth token is a personal + * authorization that spans all orgs the user belongs to. + */ + +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("github_credentials") + .addColumn("user_id", "text", (col) => col.primaryKey()) + .addColumn("access_token", "text", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .addColumn("updated_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("github_credentials").execute(); +} diff --git a/apps/mesh/migrations/064-github-installation-id.ts b/apps/mesh/migrations/064-github-installation-id.ts new file mode 100644 index 0000000000..f633e17d6b --- /dev/null +++ b/apps/mesh/migrations/064-github-installation-id.ts @@ -0,0 +1,31 @@ +/** + * Adds installation_id to github_credentials and makes access_token nullable. + * + * When using the GitHub App installation flow without OAuth, we store the + * installation_id instead of a user access token. The installation_id is used + * to generate short-lived installation access tokens via the GitHub App JWT. + */ + +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("github_credentials") + .addColumn("installation_id", "text") + .execute(); + + await sql`ALTER TABLE github_credentials ALTER COLUMN access_token DROP NOT NULL`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute( + db, + ); + + await db.schema + .alterTable("github_credentials") + .dropColumn("installation_id") + .execute(); +} diff --git a/apps/mesh/migrations/index.ts b/apps/mesh/migrations/index.ts index 7a8024d4e6..0e487d6280 100644 --- a/apps/mesh/migrations/index.ts +++ b/apps/mesh/migrations/index.ts @@ -61,6 +61,8 @@ import * as migration059kv from "./059-kv.ts"; import * as migration060memberindex from "./060-member-index.ts"; import * as migration061downstreamtokenconnectionindex from "./061-downstream-token-connection-index.ts"; import * as migration062privateregistry from "./062-private-registry.ts"; +import * as migration063githubcredentials from "./063-github-credentials.ts"; +import * as migration064githubinstallationid from "./064-github-installation-id.ts"; /** * Core migrations for the Mesh application. @@ -135,6 +137,8 @@ const migrations: Record = { "061-downstream-token-connection-index": migration061downstreamtokenconnectionindex, "062-private-registry": migration062privateregistry, + "063-github-credentials": migration063githubcredentials, + "064-github-installation-id": migration064githubinstallationid, }; export default migrations; diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index c5d3d74672..28165e35dc 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -38,6 +38,7 @@ import orgSsoRoutes from "./routes/org-sso"; import { createDecopilotRoutes } from "./routes/decopilot"; import downstreamTokenRoutes from "./routes/downstream-token"; import decoSitesRoutes from "./routes/deco-sites"; +import githubReposRoutes from "./routes/github-repos"; import virtualMcpRoutes from "./routes/virtual-mcp"; import oauthProxyRoutes, { fetchAuthorizationServerMetadata, @@ -1391,6 +1392,9 @@ export async function createApp(options: CreateAppOptions = {}) { // Deco.cx sites list (requires meshContext / auth) app.route("/api/deco-sites", decoSitesRoutes); + // GitHub repos OAuth + listing (requires meshContext / auth) + app.route("/api/github-repos", githubReposRoutes); + // ============================================================================ // Server Plugin Routes // ============================================================================ diff --git a/apps/mesh/src/api/routes/deco-sites.ts b/apps/mesh/src/api/routes/deco-sites.ts index 7f25fb4c89..494bbb631d 100644 --- a/apps/mesh/src/api/routes/deco-sites.ts +++ b/apps/mesh/src/api/routes/deco-sites.ts @@ -14,6 +14,13 @@ import { Hono } from "hono"; import type { MeshContext } from "../../core/mesh-context"; import { getUserId } from "../../core/mesh-context"; import { fetchToolsFromMCP } from "../../tools/connection/fetch-tools"; +import { + ADMIN_MCP, + getSupabaseConfig, + supabaseGet, + resolveProfileId, + getOrCreateDecoApiKey, +} from "./deco-supabase"; type Variables = { meshContext: MeshContext }; @@ -25,104 +32,6 @@ interface SupabaseSite { thumb_url: string | null; } -async function supabaseGet( - supabaseUrl: string, - serviceKey: string, - path: string, -): Promise { - const res = await fetch(`${supabaseUrl}/rest/v1/${path}`, { - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - Accept: "application/json", - }, - }); - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - console.error(`[deco-sites] Supabase error (${res.status}): ${text}`); - throw new Error(`External service error (${res.status})`); - } - return res.json() as Promise; -} - -async function supabasePost( - supabaseUrl: string, - serviceKey: string, - table: string, - body: Record, -): Promise { - const res = await fetch(`${supabaseUrl}/rest/v1/${table}`, { - method: "POST", - headers: { - apikey: serviceKey, - Authorization: `Bearer ${serviceKey}`, - "Content-Type": "application/json", - Accept: "application/json", - Prefer: "return=representation", - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - console.error(`[deco-sites] Supabase POST error (${res.status}): ${text}`); - throw new Error(`External service error (${res.status})`); - } - const rows = (await res.json()) as T[]; - if (!rows[0]) { - throw new Error("Supabase POST returned no rows"); - } - return rows[0]; -} - -import { getSettings } from "../../settings"; - -function getSupabaseConfig(): { - supabaseUrl: string; - serviceKey: string; -} | null { - const settings = getSettings(); - const supabaseUrl = settings.decoSupabaseUrl; - const serviceKey = settings.decoSupabaseServiceKey; - if (!supabaseUrl || !serviceKey) return null; - return { supabaseUrl, serviceKey }; -} - -async function resolveProfileId( - supabaseUrl: string, - serviceKey: string, - email: string, -): Promise { - const profiles = await supabaseGet<{ user_id: string }>( - supabaseUrl, - serviceKey, - `profiles?email=eq.${encodeURIComponent(email)}&select=user_id`, - ); - return profiles[0]?.user_id ?? null; -} - -async function getOrCreateDecoApiKey( - supabaseUrl: string, - serviceKey: string, - profileId: string, -): Promise { - const existing = await supabaseGet<{ id: string }>( - supabaseUrl, - serviceKey, - `api_key?user_id=eq.${encodeURIComponent(profileId)}&select=id&limit=1`, - ); - if (existing[0]?.id) { - return existing[0].id; - } - - const created = await supabasePost<{ id: string }>( - supabaseUrl, - serviceKey, - "api_key", - { user_id: profileId }, - ); - return created.id; -} - // Require an authenticated user on every handler in this router. app.use("*", async (c, next) => { const ctx = c.get("meshContext"); @@ -212,8 +121,6 @@ app.get("/", async (c) => { } }); -const ADMIN_MCP = "https://sites-admin-mcp.decocache.com/api/mcp"; - async function fetchFaviconAsDataUrl(domain: string): Promise { try { const res = await fetch(`https://${domain}/favicon.ico`, { diff --git a/apps/mesh/src/api/routes/deco-supabase.ts b/apps/mesh/src/api/routes/deco-supabase.ts new file mode 100644 index 0000000000..d6b2951253 --- /dev/null +++ b/apps/mesh/src/api/routes/deco-supabase.ts @@ -0,0 +1,214 @@ +/** + * Shared Supabase helpers for deco.cx integration. + * + * Used by both the deco-sites and github-repos routes to interact + * with the admin.deco.cx Supabase project (profiles, teams, sites, API keys). + */ + +import { getSettings } from "../../settings"; + +export const ADMIN_MCP = "http://localhost:3001/api/mcp"; + +export function getSupabaseConfig(): { + supabaseUrl: string; + serviceKey: string; +} | null { + const settings = getSettings(); + const supabaseUrl = settings.decoSupabaseUrl; + const serviceKey = settings.decoSupabaseServiceKey; + if (!supabaseUrl || !serviceKey) return null; + return { supabaseUrl, serviceKey }; +} + +export async function supabaseGet( + supabaseUrl: string, + serviceKey: string, + path: string, +): Promise { + const res = await fetch(`${supabaseUrl}/rest/v1/${path}`, { + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + Accept: "application/json", + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + console.error(`[deco-supabase] Supabase error (${res.status}): ${text}`); + throw new Error(`External service error (${res.status})`); + } + return res.json() as Promise; +} + +export async function supabasePost( + supabaseUrl: string, + serviceKey: string, + table: string, + body: Record, +): Promise { + const res = await fetch(`${supabaseUrl}/rest/v1/${table}`, { + method: "POST", + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + "Content-Type": "application/json", + Accept: "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + console.error( + `[deco-supabase] Supabase POST error (${res.status}): ${text}`, + ); + throw new Error(`External service error (${res.status})`); + } + const rows = (await res.json()) as T[]; + if (!rows[0]) { + throw new Error("Supabase POST returned no rows"); + } + return rows[0]; +} + +export async function resolveProfileId( + supabaseUrl: string, + serviceKey: string, + email: string, +): Promise { + const profiles = await supabaseGet<{ user_id: string }>( + supabaseUrl, + serviceKey, + `profiles?email=eq.${encodeURIComponent(email)}&select=user_id`, + ); + return profiles[0]?.user_id ?? null; +} + +export async function getOrCreateDecoApiKey( + supabaseUrl: string, + serviceKey: string, + profileId: string, +): Promise { + const existing = await supabaseGet<{ id: string }>( + supabaseUrl, + serviceKey, + `api_key?user_id=eq.${encodeURIComponent(profileId)}&select=id&limit=1`, + ); + if (existing[0]?.id) { + return existing[0].id; + } + + const created = await supabasePost<{ id: string }>( + supabaseUrl, + serviceKey, + "api_key", + { user_id: profileId }, + ); + return created.id; +} + +/** + * Find or create a deco.cx site by name. + * + * If a site with the given name already exists, ensures the user is a member + * of its team and returns the existing site. Otherwise creates a new team, + * membership, and site. + * + * TODO: In the future, instead of creating a new team per site, allow the + * user to pick an existing team or use their personal team. + */ +export async function getOrCreateDecoSite( + supabaseUrl: string, + serviceKey: string, + opts: { + siteName: string; + profileId: string; + repoOwner: string; + repoName: string; + installationId: string; + }, +): Promise<{ siteId: number; siteName: string }> { + const { siteName, profileId, repoOwner, repoName, installationId } = opts; + + const existing = await supabaseGet<{ + id: number; + name: string; + team: number; + }>( + supabaseUrl, + serviceKey, + `sites?name=eq.${encodeURIComponent(siteName)}&select=id,name,team`, + ); + + if (existing[0]) { + const site = existing[0]; + + const membership = await supabaseGet<{ id: number }>( + supabaseUrl, + serviceKey, + `members?user_id=eq.${encodeURIComponent(profileId)}&team_id=eq.${site.team}&deleted_at=is.null&select=id&limit=1`, + ); + + if (membership.length === 0) { + await supabasePost<{ id: number }>(supabaseUrl, serviceKey, "members", { + user_id: profileId, + team_id: site.team, + }); + } + + return { siteId: site.id, siteName: site.name }; + } + + // TODO: Let the user pick an existing team instead of always creating a new one. + const team = await supabasePost<{ id: number }>( + supabaseUrl, + serviceKey, + "teams", + { + name: siteName, + slug: siteName, + owner_user_id: profileId, + }, + ); + + const member = await supabasePost<{ id: number }>( + supabaseUrl, + serviceKey, + "members", + { + user_id: profileId, + team_id: team.id, + admin: true, + }, + ); + + const ADMIN_ROLE_ID = 4; + + await supabasePost<{ id: number }>(supabaseUrl, serviceKey, "member_roles", { + member_id: member.id, + role_id: ADMIN_ROLE_ID, + }); + + const site = await supabasePost<{ id: number; name: string }>( + supabaseUrl, + serviceKey, + "sites", + { + name: siteName, + team: team.id, + domains: [{ domain: `${siteName}.deco.site`, production: true }], + metadata: { + adminVersion: 2, + selfHosting: { + enabled: true, + repoName, + repoOwner, + connectedAt: new Date().toISOString(), + githubInstallationId: installationId, + }, + }, + }, + ); + + return { siteId: site.id, siteName: site.name }; +} diff --git a/apps/mesh/src/api/routes/github-repos.ts b/apps/mesh/src/api/routes/github-repos.ts new file mode 100644 index 0000000000..bd0525bed6 --- /dev/null +++ b/apps/mesh/src/api/routes/github-repos.ts @@ -0,0 +1,608 @@ +/** + * GitHub Repos API Route + * + * Lets users install the GitHub App, select repositories, and import them + * as projects during Site Editor onboarding. + * + * Required env vars: + * GITHUB_APP_SLUG – App slug (for installation URL) + * GITHUB_APP_ID – Numeric App ID (for JWT generation) + * GITHUB_APP_PRIVATE_KEY – PEM private key (for JWT signing) + * + * Flow: + * 1. GET /auth/url → returns { url } to open GitHub App installation + * 2. GET /auth/callback → stores installation_id, closes popup + * 3. GET /status → { connected: boolean } + * 4. DELETE /auth/disconnect → removes stored installation + * 5. GET / → lists repos via installation access token + * 6. POST /connection → creates a connection for a selected repo + */ + +import { createSign } from "node:crypto"; +import { Hono } from "hono"; +import type { MeshContext } from "../../core/mesh-context"; +import { getUserId } from "../../core/mesh-context"; +import { getSettings } from "../../settings"; +import { fetchToolsFromMCP } from "../../tools/connection/fetch-tools"; +import { + ADMIN_MCP, + getSupabaseConfig, + resolveProfileId, + getOrCreateDecoApiKey, + getOrCreateDecoSite, +} from "./deco-supabase"; + +type Variables = { meshContext: MeshContext }; + +const app = new Hono<{ Variables: Variables }>(); + +// --------------------------------------------------------------------------- +// In-memory state map for CSRF protection (short-lived, process-scoped) +// --------------------------------------------------------------------------- + +interface StateEntry { + userId: string; + expiresAt: number; +} + +const stateMap = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +function pruneExpiredStates() { + const now = Date.now(); + for (const [k, v] of stateMap) { + if (v.expiresAt < now) stateMap.delete(k); + } +} + +// --------------------------------------------------------------------------- +// GitHub App JWT + Installation Access Token +// --------------------------------------------------------------------------- + +function base64url(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +function normalizePem(raw: string): string { + const trimmed = raw.replace(/\\n/g, "\n").trim(); + const match = trimmed.match( + /-----BEGIN ([A-Z ]+)-----(.+?)-----END ([A-Z ]+)-----/s, + ); + if (!match) return trimmed; + const tag = match[1]!; + const b64 = match[2]!.replace(/\s/g, ""); + const lines = b64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${tag}-----\n${lines.join("\n")}\n-----END ${tag}-----\n`; +} + +function generateGitHubAppJWT(appId: string, privateKeyPem: string): string { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url( + JSON.stringify({ iss: appId, iat: now - 60, exp: now + 600 }), + ); + const unsigned = `${header}.${payload}`; + const signer = createSign("RSA-SHA256"); + signer.update(unsigned); + return `${unsigned}.${signer.sign(privateKeyPem, "base64url")}`; +} + +function getAppConfig() { + const { githubAppSlug, githubAppId, githubAppPrivateKey } = getSettings(); + if (!githubAppSlug || !githubAppId || !githubAppPrivateKey) return null; + return { + slug: githubAppSlug, + id: githubAppId, + pem: normalizePem(githubAppPrivateKey), + }; +} + +async function createInstallationToken( + installationId: string, +): Promise { + const cfg = getAppConfig(); + if (!cfg) { + throw new Error( + "GITHUB_APP_SLUG, GITHUB_APP_ID, and GITHUB_APP_PRIVATE_KEY are required", + ); + } + const jwt = generateGitHubAppJWT(cfg.id, cfg.pem); + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `Installation token request failed: ${res.status} – ${body}`, + ); + } + const data = (await res.json()) as { token: string }; + return data.token; +} + +// --------------------------------------------------------------------------- +// DB helpers — read/write/delete the installation_id for a user +// --------------------------------------------------------------------------- + +async function getInstallationId( + ctx: MeshContext, + userId: string, +): Promise { + const row = await ctx.db + .selectFrom("github_credentials") + .select("installation_id") + .where("user_id", "=", userId) + .executeTakeFirst(); + return row?.installation_id ?? null; +} + +async function upsertInstallation( + ctx: MeshContext, + userId: string, + installationId: string, +): Promise { + const now = new Date().toISOString(); + await ctx.db + .insertInto("github_credentials") + .values({ + user_id: userId, + installation_id: installationId, + access_token: null, + created_at: now, + updated_at: now, + }) + .onConflict((oc) => + oc + .column("user_id") + .doUpdateSet({ installation_id: installationId, updated_at: now }), + ) + .execute(); +} + +async function deleteInstallation( + ctx: MeshContext, + userId: string, +): Promise { + await ctx.db + .deleteFrom("github_credentials") + .where("user_id", "=", userId) + .execute(); +} + +// --------------------------------------------------------------------------- +// Auth guard (every handler except the callback which is a GitHub redirect) +// --------------------------------------------------------------------------- + +for (const path of [ + "/auth/url", + "/auth/disconnect", + "/status", + "/", + "/connection", +]) { + app.use(path, async (c, next) => { + if (!c.get("meshContext").auth.user?.id) + return c.json({ error: "Unauthorized" }, 401); + return next(); + }); +} + +// --------------------------------------------------------------------------- +// GET /auth/url +// --------------------------------------------------------------------------- + +app.get("/auth/url", (c) => { + const cfg = getAppConfig(); + if (!cfg) { + return c.json({ error: "GitHub integration is not configured" }, 503); + } + + const ctx = c.get("meshContext"); + const userId = ctx.auth.user!.id; + + pruneExpiredStates(); + const nonce = crypto.randomUUID(); + stateMap.set(nonce, { userId, expiresAt: Date.now() + STATE_TTL_MS }); + + const callbackUrl = new URL( + "/api/github-repos/auth/callback", + c.req.url, + ).toString(); + + const params = new URLSearchParams({ + state: nonce, + redirect_uri: callbackUrl, + }); + const url = `https://github.com/apps/${cfg.slug}/installations/new?${params.toString()}`; + + return c.json({ url }); +}); + +// --------------------------------------------------------------------------- +// GET /auth/callback +// +// GitHub redirects here after the user installs (or updates) the App. +// +// Possible parameter combinations: +// a) state + installation_id → fresh install +// b) installation_id + setup_action=update → repo access change (no state) +// --------------------------------------------------------------------------- + +app.get("/auth/callback", async (c) => { + const state = c.req.query("state"); + const installationId = c.req.query("installation_id"); + const setupAction = c.req.query("setup_action"); // "install" | "update" + + const successHtml = /* html */ ` + +GitHub Connected + + + +`; + + const errorHtml = (msg: string) => /* html */ ` + +GitHub Error + + + +`; + + // (b) Updating an existing installation (changing repo access). + if (!state && installationId && setupAction === "update") { + console.info(`[github-repos] installation updated: ${installationId}`); + return c.html(successHtml); + } + + // (a) Fresh install: state + installation_id. + if (!state) return c.html(errorHtml("Missing state parameter"), 400); + if (!installationId) { + return c.html(errorHtml("Missing installation_id"), 400); + } + + pruneExpiredStates(); + const entry = stateMap.get(state); + if (!entry || entry.expiresAt < Date.now()) { + return c.html(errorHtml("Invalid or expired state"), 400); + } + stateMap.delete(state); + + const ctx = c.get("meshContext"); + if (!ctx) return c.html(errorHtml("Session context unavailable"), 500); + + try { + await upsertInstallation(ctx, entry.userId, installationId); + } catch (err) { + console.error("[github-repos] failed to persist installation:", err); + return c.html(errorHtml("Failed to save installation"), 500); + } + + return c.html(successHtml); +}); + +// --------------------------------------------------------------------------- +// GET /status +// --------------------------------------------------------------------------- + +app.get("/status", async (c) => { + const ctx = c.get("meshContext"); + const userId = ctx.auth.user!.id; + const instId = await getInstallationId(ctx, userId); + + if (!instId) { + return c.json({ connected: false, configureUrl: null }); + } + + let configureUrl = `https://github.com/settings/installations/${instId}`; + + const cfg = getAppConfig(); + if (cfg) { + try { + const jwt = generateGitHubAppJWT(cfg.id, cfg.pem); + const res = await fetch( + `https://api.github.com/app/installations/${instId}`, + { + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (res.ok) { + const data = (await res.json()) as { + account: { login: string; type: string }; + }; + const { login, type } = data.account; + configureUrl = + type === "Organization" + ? `https://github.com/organizations/${login}/settings/installations/${instId}` + : `https://github.com/settings/installations/${instId}`; + } + } catch { + // Fall back to the personal-account URL format. + } + } + + return c.json({ connected: true, configureUrl }); +}); + +// --------------------------------------------------------------------------- +// DELETE /auth/disconnect +// --------------------------------------------------------------------------- + +app.delete("/auth/disconnect", async (c) => { + const ctx = c.get("meshContext"); + const userId = ctx.auth.user!.id; + await deleteInstallation(ctx, userId); + return c.json({ ok: true }); +}); + +// --------------------------------------------------------------------------- +// GET / +// Lists repositories accessible through the stored installation. +// --------------------------------------------------------------------------- + +interface GitHubRepo { + id: number; + full_name: string; + name: string; + description: string | null; + private: boolean; + html_url: string; + default_branch: string; + owner: { login: string; avatar_url: string }; + updated_at: string | null; +} + +app.get("/", async (c) => { + const ctx = c.get("meshContext"); + const userId = ctx.auth.user!.id; + const installationId = await getInstallationId(ctx, userId); + + if (!installationId) { + return c.json({ connected: false, repos: [] }); + } + + const cfg = getAppConfig(); + if (!cfg) { + return c.json({ error: "GitHub integration is not configured" }, 503); + } + + try { + const token = await createInstallationToken(installationId); + const res = await fetch( + "https://api.github.com/installation/repositories?per_page=100", + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + if (!res.ok) { + const body = await res.text(); + console.error( + `[github-repos] installation repos error: ${res.status}`, + body, + ); + return c.json({ error: "Failed to fetch repositories" }, 502); + } + const data = (await res.json()) as { + repositories: GitHubRepo[]; + total_count: number; + }; + return c.json({ + connected: true, + repos: data.repositories ?? [], + configureUrl: `https://github.com/settings/installations/${installationId}`, + installUrl: `https://github.com/apps/${cfg.slug}/installations/new`, + }); + } catch (err) { + console.error("[github-repos] GET repos error:", err); + return c.json({ error: "Failed to fetch repositories" }, 502); + } +}); + +// --------------------------------------------------------------------------- +// POST /connection +// Creates a GitHub connection and, when Supabase is configured, also +// provisions a deco.cx site and an admin MCP connection for it. +// --------------------------------------------------------------------------- + +app.post("/connection", async (c) => { + const ctx = c.get("meshContext"); + const userId = getUserId(ctx); + if (!userId) return c.json({ error: "Unauthorized" }, 401); + + const email = ctx.auth.user?.email; + + const installationId = await getInstallationId(ctx, userId); + if (!installationId) { + return c.json({ error: "GitHub App not installed" }, 401); + } + + const connectionToken = await createInstallationToken(installationId); + + let body: { + repoFullName: string; + connId: string; + adminConnId: string; + orgId: string; + }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid request body" }, 400); + } + + const { repoFullName, connId, adminConnId, orgId } = body; + if (!repoFullName || !connId || !orgId) { + return c.json( + { error: "repoFullName, connId, and orgId are required" }, + 400, + ); + } + + if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repoFullName)) { + return c.json({ error: "Invalid repoFullName" }, 400); + } + + const membership = await ctx.db + .selectFrom("member") + .select("member.id") + .where("member.userId", "=", userId) + .where("member.organizationId", "=", orgId) + .executeTakeFirst(); + + if (!membership) return c.json({ error: "Forbidden" }, 403); + + const [owner, repoName] = repoFullName.split("/"); + + try { + const connection = await ctx.storage.connections.create({ + id: connId, + organization_id: orgId, + created_by: userId, + title: `GitHub — ${repoFullName}`, + description: `GitHub repository: ${repoFullName}`, + connection_type: "HTTP", + connection_url: "https://api.githubcopilot.com/mcp/", + connection_token: connectionToken, + connection_headers: null, + oauth_config: null, + configuration_state: { + GITHUB_REPO_OWNER: owner, + GITHUB_REPO_NAME: repoName, + GITHUB_REPO_FULL_NAME: repoFullName, + }, + metadata: { source: "github-import" }, + icon: null, + app_name: "GitHub", + app_id: null, + tools: null, + configuration_scopes: null, + }); + + // ----------------------------------------------------------------------- + // Deco.cx site + admin MCP connection (best-effort). + // Skipped when Supabase isn't configured or the user has no deco profile. + // ----------------------------------------------------------------------- + let createdAdminConnId: string | null = null; + let decoSiteName: string | null = null; + + const sbConfig = getSupabaseConfig(); + if (sbConfig && email && adminConnId) { + try { + const { supabaseUrl, serviceKey } = sbConfig; + const profileId = await resolveProfileId( + supabaseUrl, + serviceKey, + email, + ); + + if (profileId) { + const siteName = `${owner}-${repoName}` + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 38); + + const { siteName: finalSiteName } = await getOrCreateDecoSite( + supabaseUrl, + serviceKey, + { + siteName, + profileId, + repoOwner: owner!, + repoName: repoName!, + installationId, + }, + ); + decoSiteName = finalSiteName; + + const apiKey = await getOrCreateDecoApiKey( + supabaseUrl, + serviceKey, + profileId, + ); + + const fetchResult = await fetchToolsFromMCP({ + id: `pending-${adminConnId}`, + title: `deco.cx — ${finalSiteName}`, + connection_type: "HTTP", + connection_url: ADMIN_MCP, + connection_token: apiKey, + }).catch(() => null); + const tools = fetchResult?.tools?.length ? fetchResult.tools : null; + const configuration_scopes = fetchResult?.scopes?.length + ? fetchResult.scopes + : null; + + const adminConn = await ctx.storage.connections.create({ + id: adminConnId, + organization_id: orgId, + created_by: userId, + title: `deco.cx — ${finalSiteName}`, + description: `Admin MCP for deco.cx site: ${finalSiteName}`, + connection_type: "HTTP", + connection_url: ADMIN_MCP, + connection_token: apiKey, + connection_headers: null, + oauth_config: null, + configuration_state: { SITE_NAME: finalSiteName }, + metadata: { source: "github-import-deco" }, + icon: null, + app_name: "deco.cx", + app_id: null, + tools, + configuration_scopes, + }); + + createdAdminConnId = adminConn.id; + } + } catch (err) { + console.warn( + "[github-repos] deco.cx site/admin-mcp creation failed (non-fatal):", + err, + ); + } + } + + return c.json({ + connId: connection.id, + adminConnId: createdAdminConnId, + decoSiteName, + }); + } catch (err) { + console.error("[github-repos] POST /connection error:", err); + return c.json({ error: "Failed to create connection" }, 500); + } +}); + +export default app; diff --git a/apps/mesh/src/settings/resolve-config.ts b/apps/mesh/src/settings/resolve-config.ts index 0f157ffc0a..818e8ecd9a 100644 --- a/apps/mesh/src/settings/resolve-config.ts +++ b/apps/mesh/src/settings/resolve-config.ts @@ -110,6 +110,11 @@ export function resolveConfig( // External service credentials decoSupabaseUrl: envVars.DECO_SUPABASE_URL, decoSupabaseServiceKey: envVars.DECO_SUPABASE_SERVICE_KEY, + + // GitHub App + githubAppSlug: envVars.GITHUB_APP_SLUG, + githubAppId: envVars.GITHUB_APP_ID, + githubAppPrivateKey: envVars.GITHUB_APP_PRIVATE_KEY, }; return { diff --git a/apps/mesh/src/settings/types.ts b/apps/mesh/src/settings/types.ts index d1564b2c81..224723431c 100644 --- a/apps/mesh/src/settings/types.ts +++ b/apps/mesh/src/settings/types.ts @@ -62,6 +62,11 @@ export interface Settings { // External service credentials (optional) decoSupabaseUrl: string | undefined; decoSupabaseServiceKey: string | undefined; + + // GitHub App (optional — enables GitHub import in onboarding) + githubAppSlug: string | undefined; + githubAppId: string | undefined; + githubAppPrivateKey: string | undefined; } export interface CliFlags { diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index dd719f9e44..a21bb49e30 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -914,6 +914,18 @@ export interface AutomationTrigger { created_at: string; } +// ============================================================================ +// GitHub Credentials +// ============================================================================ + +export interface GitHubCredentialTable { + user_id: string; + access_token: string | null; // Encrypted via vault (null when using installation-only flow) + installation_id: string | null; // GitHub App installation ID + created_at: ColumnType; + updated_at: ColumnType; +} + /** * Trigger callback token table - stores hashed tokens for external MCP callbacks */ @@ -995,4 +1007,7 @@ export interface Database { // Generic org-scoped KV store kv: KVTable; + + // GitHub OAuth credentials (per user, encrypted) + github_credentials: GitHubCredentialTable; } diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index b72d315f89..9f10cf645a 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useNavigate, useMatch } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Popover, PopoverContent, @@ -41,6 +42,73 @@ import { authClient } from "@/web/lib/auth-client"; import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; +import { KEYS } from "@/web/lib/query-keys"; + +function GitHubConnectedControl({ userId }: { userId: string | undefined }) { + const queryClient = useQueryClient(); + + const { data: statusData, isLoading } = useQuery({ + queryKey: KEYS.githubStatus(userId), + queryFn: async () => { + const res = await fetch("/api/github-repos/status"); + if (!res.ok) throw new Error("Failed to check GitHub status"); + return res.json() as Promise<{ + connected: boolean; + configureUrl: string | null; + }>; + }, + enabled: Boolean(userId), + staleTime: 30_000, + }); + + const disconnectMutation = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/github-repos/auth/disconnect", { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to disconnect"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.githubStatus(userId) }); + queryClient.invalidateQueries({ queryKey: KEYS.githubRepos(userId) }); + toast.success("GitHub disconnected"); + }, + onError: () => { + toast.error("Failed to disconnect GitHub"); + }, + }); + + if (isLoading) { + return Checking…; + } + + if (!statusData?.connected) { + return Not connected; + } + + return ( +
+ {statusData.configureUrl && ( + + Manage + + )} + +
+ ); +} function getOrgColorStyle(name: string): { backgroundColor: string; @@ -297,6 +365,20 @@ function AccountPopoverContent({ ))} + + {/* Connected accounts */} +
+

+ Connected accounts +

+
+
+ + GitHub +
+ +
+
{/* Bottom bar: theme + sound + version */} @@ -409,6 +491,20 @@ function AccountPopoverContent({ + {/* Connected accounts */} +
+

+ Connected accounts +

+
+
+ + GitHub +
+ +
+
+ {/* Bottom bar: theme toggles + sound + version */}
diff --git a/apps/mesh/src/web/components/home/site-editor-onboarding-modal.tsx b/apps/mesh/src/web/components/home/site-editor-onboarding-modal.tsx index 64684165fd..996ccaa243 100644 --- a/apps/mesh/src/web/components/home/site-editor-onboarding-modal.tsx +++ b/apps/mesh/src/web/components/home/site-editor-onboarding-modal.tsx @@ -25,6 +25,7 @@ import { Button } from "@deco/ui/components/button.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; import { ImportFromDecoDialog } from "@/web/components/import-from-deco-dialog.tsx"; +import { ImportFromGitHubDialog } from "@/web/components/import-from-github-dialog.tsx"; import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; interface OnboardingCard { @@ -46,8 +47,8 @@ const CARDS: OnboardingCard[] = [ { id: "github", title: "Existing GitHub project", - buttonLabel: "Coming Soon", - comingSoon: true, + buttonLabel: "Import repository", + comingSoon: false, image: "/import-github.png", }, { @@ -145,13 +146,17 @@ export function SiteEditorOnboardingModal({ open, onOpenChange, }: SiteEditorOnboardingModalProps) { - const [importOpen, setImportOpen] = useState(false); + const [importDecoOpen, setImportDecoOpen] = useState(false); + const [importGitHubOpen, setImportGitHubOpen] = useState(false); const isMobile = useIsMobile(); const handleCardAction = (cardId: string) => { if (cardId === "deco") { onOpenChange(false); - setImportOpen(true); + setImportDecoOpen(true); + } else if (cardId === "github") { + onOpenChange(false); + setImportGitHubOpen(true); } }; @@ -190,10 +195,19 @@ export function SiteEditorOnboardingModal({ )} { + setImportDecoOpen(false); + onOpenChange(true); + }} + /> + + { - setImportOpen(false); + setImportGitHubOpen(false); onOpenChange(true); }} /> diff --git a/apps/mesh/src/web/components/import-from-github-dialog.tsx b/apps/mesh/src/web/components/import-from-github-dialog.tsx new file mode 100644 index 0000000000..95b3905548 --- /dev/null +++ b/apps/mesh/src/web/components/import-from-github-dialog.tsx @@ -0,0 +1,640 @@ +import { useState, useRef, useCallback } from "react"; +import { toast } from "sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ArrowLeft } from "@untitledui/icons"; +import { authClient } from "@/web/lib/auth-client"; +import { generatePrefixedId } from "@/shared/utils/generate-id"; +import { KEYS } from "@/web/lib/query-keys"; +import { generateSlug } from "@/web/lib/slug"; +import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; + +interface GitHubRepo { + id: number; + full_name: string; + name: string; + description: string | null; + private: boolean; + html_url: string; + owner: { login: string; avatar_url: string }; + updated_at: string | null; +} + +interface GitHubReposResponse { + connected: boolean; + repos: GitHubRepo[]; + configureUrl: string | null; + installUrl: string | null; + needsInstall?: boolean; // no installation found — user must install the app first + error?: string; +} + +interface GitHubStatusResponse { + connected: boolean; +} + +async function loadGitHubStatus(): Promise { + const res = await fetch("/api/github-repos/status"); + if (!res.ok) throw new Error("Failed to check GitHub status"); + return res.json() as Promise; +} + +async function loadGitHubRepos(): Promise { + const res = await fetch("/api/github-repos"); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error( + body.error ?? `Failed to load repositories (${res.status})`, + ); + } + return res.json() as Promise; +} + +interface ImportFromGitHubDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onBack?: () => void; +} + +type VirtualMCPCreateOutput = { + item: { + id: string; + title: string; + metadata?: { + ui?: { slug?: string } | null; + migrated_project_slug?: string; + } | null; + }; +}; + +export function ImportFromGitHubDialog({ + open, + onOpenChange, + onBack, +}: ImportFromGitHubDialogProps) { + const { org } = useProjectContext(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: session } = authClient.useSession(); + const userId = session?.user?.id; + + const [selectedRepo, setSelectedRepo] = useState(null); + const [search, setSearch] = useState(""); + const [connectingOAuth, setConnectingOAuth] = useState(false); + const [waitingForInstall, setWaitingForInstall] = useState(false); + const popupRef = useRef(null); + const pollRef = useRef | null>(null); + + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + const { + data: statusData, + isLoading: isStatusLoading, + refetch: refetchStatus, + } = useQuery({ + queryKey: KEYS.githubStatus(userId), + queryFn: loadGitHubStatus, + enabled: open && Boolean(userId), + staleTime: 30_000, + retry: false, + }); + + const isConnected = statusData?.connected ?? false; + + const { + data: reposData, + isLoading: isReposLoading, + error: reposError, + refetch: refetchRepos, + } = useQuery({ + queryKey: KEYS.githubRepos(userId), + queryFn: loadGitHubRepos, + enabled: open && isConnected, + staleTime: 60_000, + retry: false, + }); + + const repos = reposData?.repos ?? []; + + const stopPolling = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + setWaitingForInstall(false); + }, []); + + const startPollingForRepos = useCallback(() => { + setWaitingForInstall(true); + stopPolling(); + pollRef.current = setInterval(async () => { + const result = await refetchRepos(); + if ((result.data?.repos?.length ?? 0) > 0) { + stopPolling(); + } + }, 4000); + }, [refetchRepos, stopPolling]); + + const handleClose = (nextOpen: boolean) => { + if (!nextOpen) { + setSelectedRepo(null); + setSearch(""); + setConnectingOAuth(false); + stopPolling(); + } + onOpenChange(nextOpen); + }; + + const filteredRepos = repos.filter( + (r) => + r.full_name.toLowerCase().includes(search.toLowerCase()) || + (r.description ?? "").toLowerCase().includes(search.toLowerCase()), + ); + + const isSelectedVisible = + !selectedRepo || filteredRepos.some((r) => r.full_name === selectedRepo); + + const handleConnectGitHub = async () => { + setConnectingOAuth(true); + try { + const res = await fetch("/api/github-repos/auth/url"); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + toast.error(body.error ?? "Failed to start GitHub OAuth"); + return; + } + const data = (await res.json()) as { url: string }; + + const popup = window.open( + data.url, + "github-oauth", + "width=600,height=700,left=200,top=100", + ); + popupRef.current = popup; + + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === "github-oauth-success") { + window.removeEventListener("message", handleMessage); + setConnectingOAuth(false); + queryClient.invalidateQueries({ + queryKey: KEYS.githubStatus(userId), + }); + queryClient.invalidateQueries({ + queryKey: KEYS.githubRepos(userId), + }); + refetchStatus(); + + // Repo selection happened inside the popup (installation flow). + // The repos list will refresh via the query invalidation above. + } else if (event.data?.type === "github-oauth-error") { + window.removeEventListener("message", handleMessage); + setConnectingOAuth(false); + toast.error(event.data.error ?? "GitHub OAuth failed"); + } + }; + + window.addEventListener("message", handleMessage); + + // Detect popup closed without completing OAuth + const pollClosed = setInterval(() => { + if (popup?.closed) { + clearInterval(pollClosed); + window.removeEventListener("message", handleMessage); + setConnectingOAuth(false); + } + }, 500); + } catch { + setConnectingOAuth(false); + toast.error("Failed to start GitHub OAuth"); + } + }; + + const disconnectMutation = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/github-repos/auth/disconnect", { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to disconnect"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.githubStatus(userId) }); + queryClient.invalidateQueries({ queryKey: KEYS.githubRepos(userId) }); + setSelectedRepo(null); + setSearch(""); + }, + onError: () => { + toast.error("Failed to disconnect GitHub account"); + }, + }); + + const importMutation = useMutation({ + mutationFn: async (repoFullName: string) => { + const connId = generatePrefixedId("conn"); + const adminConnId = generatePrefixedId("conn"); + + const connRes = await fetch("/api/github-repos/connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoFullName, + connId, + adminConnId, + orgId: org.id, + }), + }); + const connBody = (await connRes.json().catch(() => ({}))) as { + connId?: string; + adminConnId?: string | null; + decoSiteName?: string | null; + error?: string; + }; + if (!connRes.ok) { + throw new Error( + connBody.error ?? `Failed to create connection (${connRes.status})`, + ); + } + + const hasAdminConn = Boolean(connBody.adminConnId); + + const repoName = repoFullName.split("/")[1] ?? repoFullName; + const slug = generateSlug(repoName); + + const connections = [{ connection_id: connId }]; + if (hasAdminConn) { + connections.push({ connection_id: connBody.adminConnId! }); + } + + const adminId = connBody.adminConnId; + const pinnedViews = hasAdminConn + ? [ + { + connectionId: adminId, + toolName: "file_explorer", + label: "Preview", + icon: null, + }, + { + connectionId: adminId, + toolName: "fetch_assets", + label: "Assets", + icon: null, + }, + { + connectionId: adminId, + toolName: "get_monitor_data", + label: "Monitor", + icon: null, + }, + ] + : []; + + const defaultMainView = hasAdminConn + ? { + type: "ext-apps", + id: adminId, + toolName: "file_explorer", + } + : null; + + const result = (await client.callTool({ + name: "COLLECTION_VIRTUAL_MCP_CREATE", + arguments: { + data: { + title: repoFullName, + description: `GitHub repository: ${repoFullName}`, + pinned: true, + icon: "icon://Code02?color=slate", + subtype: "project", + metadata: { + instructions: null, + enabled_plugins: [], + ui: { + banner: null, + bannerColor: "#1F2328", + icon: null, + themeColor: "#1F2328", + slug, + pinnedViews, + layout: { defaultMainView }, + }, + }, + connections, + }, + }, + })) as { structuredContent?: unknown }; + + const payload = (result.structuredContent ?? + result) as VirtualMCPCreateOutput; + + return { + slug, + virtualMcpId: payload.item.id, + connId, + item: payload.item, + }; + }, + onSuccess: ({ slug, virtualMcpId, item }) => { + queryClient.setQueryData( + KEYS.collectionItem(client, org.id, "", "VIRTUAL_MCP", virtualMcpId), + { item }, + ); + + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + key[1] === org.id && + key[3] === "collection" && + key[4] === "VIRTUAL_MCP" + ); + }, + }); + queryClient.invalidateQueries({ queryKey: KEYS.projects(org.id) }); + toast.success(`Imported ${slug} from GitHub`); + handleClose(false); + localStorage.setItem("mesh:sidebar-open", JSON.stringify(false)); + navigate({ + to: "/$org/$virtualMcpId", + params: { + org: org.slug, + virtualMcpId, + }, + }); + }, + onError: (err) => { + toast.error( + "Import failed: " + + (err instanceof Error ? err.message : "Unknown error"), + ); + }, + }); + + const isLoading = isStatusLoading || (isConnected && isReposLoading); + + return ( + + + + Import from GitHub + + +
+ + + Import from GitHub + +
+ + {!isStatusLoading && !isConnected ? ( +
+
+

+ Connect your GitHub account +

+

+ Authorize the GitHub App to list repositories you've given + access to. +

+
+ +
+ ) : ( + <> +
+
+ { + if (e.key === "Escape") setSearch(""); + }} + /> +
+ {(reposData?.configureUrl ?? reposData?.installUrl) && ( + + {reposData.configureUrl + ? "Configure access on GitHub" + : "Install GitHub App"} + + + )} +
+ +
+ {isLoading && ( +
+ Loading repositories... +
+ )} + + {!isLoading && !reposError && repos.length === 0 && ( +
+ {reposData?.needsInstall ? ( + <> +
+

+ No repository access yet +

+

+ Install the GitHub App on your account and select + which repositories to grant access to. +

+
+ {reposData.installUrl && ( + { + // Invalidate after user returns from GitHub + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: KEYS.githubRepos(userId), + }); + }, 3000); + }} + > + + + )} + + ) : ( +

+ No repositories found. +

+ )} +
+ )} + + {!isLoading && reposError && ( +
+ {reposError instanceof Error + ? reposError.message + : "Failed to load repositories"} +
+ )} + + {!isLoading && repos.length > 0 && ( +
+ {filteredRepos.length === 0 && ( +

+ No repositories match “{search}” +

+ )} + {filteredRepos.map((repo) => { + const isSelected = selectedRepo === repo.full_name; + return ( + + ); + })} +
+ )} +
+ + + + {reposData?.needsInstall && ( + + )} + + + + + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 857fffd0df..f4b6746a4c 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -275,4 +275,10 @@ export const KEYS = { // Deco sites (scoped by user email) decoSites: (email: string | undefined) => ["deco-sites", email] as const, + + // GitHub repos (scoped by user id) + githubStatus: (userId: string | undefined) => + ["github-status", userId] as const, + githubRepos: (userId: string | undefined) => + ["github-repos", userId] as const, } as const;