Skip to content

Commit 5cddbc6

Browse files
committed
feat(github-import): provision deco.cx site and admin MCP on GitHub import
When importing a GitHub repository, the flow now also: - Creates a deco.cx site on Supabase with selfHosting metadata - Creates an admin MCP connection pointing to sites-admin-mcp - Links both GitHub and admin connections to the Virtual MCP project - Sets up pinned views (Preview, Assets, Monitor) when admin conn is available Also: - Extracts shared Supabase helpers into deco-supabase.ts - Adds "Manage" link to GitHub connected accounts (org-aware URL) - Gracefully skips deco.cx provisioning when Supabase is not configured Made-with: Cursor
1 parent e00eec8 commit 5cddbc6

14 files changed

Lines changed: 1683 additions & 107 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* GitHub Credentials Migration
3+
*
4+
* Stores GitHub OAuth access tokens per user, encrypted at rest via the vault.
5+
* Scoped to the user (not the org) since a GitHub OAuth token is a personal
6+
* authorization that spans all orgs the user belongs to.
7+
*/
8+
9+
import { type Kysely, sql } from "kysely";
10+
11+
export async function up(db: Kysely<unknown>): Promise<void> {
12+
await db.schema
13+
.createTable("github_credentials")
14+
.addColumn("user_id", "text", (col) => col.primaryKey())
15+
.addColumn("access_token", "text", (col) => col.notNull())
16+
.addColumn("created_at", "timestamptz", (col) =>
17+
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
18+
)
19+
.addColumn("updated_at", "timestamptz", (col) =>
20+
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
21+
)
22+
.execute();
23+
}
24+
25+
export async function down(db: Kysely<unknown>): Promise<void> {
26+
await db.schema.dropTable("github_credentials").execute();
27+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Adds installation_id to github_credentials and makes access_token nullable.
3+
*
4+
* When using the GitHub App installation flow without OAuth, we store the
5+
* installation_id instead of a user access token. The installation_id is used
6+
* to generate short-lived installation access tokens via the GitHub App JWT.
7+
*/
8+
9+
import { type Kysely, sql } from "kysely";
10+
11+
export async function up(db: Kysely<unknown>): Promise<void> {
12+
await db.schema
13+
.alterTable("github_credentials")
14+
.addColumn("installation_id", "text")
15+
.execute();
16+
17+
await sql`ALTER TABLE github_credentials ALTER COLUMN access_token DROP NOT NULL`.execute(
18+
db,
19+
);
20+
}
21+
22+
export async function down(db: Kysely<unknown>): Promise<void> {
23+
await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute(
24+
db,
25+
);
26+
27+
await db.schema
28+
.alterTable("github_credentials")
29+
.dropColumn("installation_id")
30+
.execute();
31+
}

apps/mesh/migrations/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import * as migration059kv from "./059-kv.ts";
6161
import * as migration060memberindex from "./060-member-index.ts";
6262
import * as migration061downstreamtokenconnectionindex from "./061-downstream-token-connection-index.ts";
6363
import * as migration062privateregistry from "./062-private-registry.ts";
64+
import * as migration063githubcredentials from "./063-github-credentials.ts";
65+
import * as migration064githubinstallationid from "./064-github-installation-id.ts";
6466

6567
/**
6668
* Core migrations for the Mesh application.
@@ -135,6 +137,8 @@ const migrations: Record<string, Migration> = {
135137
"061-downstream-token-connection-index":
136138
migration061downstreamtokenconnectionindex,
137139
"062-private-registry": migration062privateregistry,
140+
"063-github-credentials": migration063githubcredentials,
141+
"064-github-installation-id": migration064githubinstallationid,
138142
};
139143

140144
export default migrations;

apps/mesh/src/api/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import orgSsoRoutes from "./routes/org-sso";
3838
import { createDecopilotRoutes } from "./routes/decopilot";
3939
import downstreamTokenRoutes from "./routes/downstream-token";
4040
import decoSitesRoutes from "./routes/deco-sites";
41+
import githubReposRoutes from "./routes/github-repos";
4142
import virtualMcpRoutes from "./routes/virtual-mcp";
4243
import oauthProxyRoutes, {
4344
fetchAuthorizationServerMetadata,
@@ -1391,6 +1392,9 @@ export async function createApp(options: CreateAppOptions = {}) {
13911392
// Deco.cx sites list (requires meshContext / auth)
13921393
app.route("/api/deco-sites", decoSitesRoutes);
13931394

1395+
// GitHub repos OAuth + listing (requires meshContext / auth)
1396+
app.route("/api/github-repos", githubReposRoutes);
1397+
13941398
// ============================================================================
13951399
// Server Plugin Routes
13961400
// ============================================================================

apps/mesh/src/api/routes/deco-sites.ts

Lines changed: 7 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import { Hono } from "hono";
1414
import type { MeshContext } from "../../core/mesh-context";
1515
import { getUserId } from "../../core/mesh-context";
1616
import { fetchToolsFromMCP } from "../../tools/connection/fetch-tools";
17+
import {
18+
ADMIN_MCP,
19+
getSupabaseConfig,
20+
supabaseGet,
21+
resolveProfileId,
22+
getOrCreateDecoApiKey,
23+
} from "./deco-supabase";
1724

1825
type Variables = { meshContext: MeshContext };
1926

@@ -25,104 +32,6 @@ interface SupabaseSite {
2532
thumb_url: string | null;
2633
}
2734

28-
async function supabaseGet<T>(
29-
supabaseUrl: string,
30-
serviceKey: string,
31-
path: string,
32-
): Promise<T[]> {
33-
const res = await fetch(`${supabaseUrl}/rest/v1/${path}`, {
34-
headers: {
35-
apikey: serviceKey,
36-
Authorization: `Bearer ${serviceKey}`,
37-
Accept: "application/json",
38-
},
39-
});
40-
if (!res.ok) {
41-
const text = await res.text().catch(() => res.statusText);
42-
console.error(`[deco-sites] Supabase error (${res.status}): ${text}`);
43-
throw new Error(`External service error (${res.status})`);
44-
}
45-
return res.json() as Promise<T[]>;
46-
}
47-
48-
async function supabasePost<T>(
49-
supabaseUrl: string,
50-
serviceKey: string,
51-
table: string,
52-
body: Record<string, unknown>,
53-
): Promise<T> {
54-
const res = await fetch(`${supabaseUrl}/rest/v1/${table}`, {
55-
method: "POST",
56-
headers: {
57-
apikey: serviceKey,
58-
Authorization: `Bearer ${serviceKey}`,
59-
"Content-Type": "application/json",
60-
Accept: "application/json",
61-
Prefer: "return=representation",
62-
},
63-
body: JSON.stringify(body),
64-
});
65-
if (!res.ok) {
66-
const text = await res.text().catch(() => res.statusText);
67-
console.error(`[deco-sites] Supabase POST error (${res.status}): ${text}`);
68-
throw new Error(`External service error (${res.status})`);
69-
}
70-
const rows = (await res.json()) as T[];
71-
if (!rows[0]) {
72-
throw new Error("Supabase POST returned no rows");
73-
}
74-
return rows[0];
75-
}
76-
77-
import { getSettings } from "../../settings";
78-
79-
function getSupabaseConfig(): {
80-
supabaseUrl: string;
81-
serviceKey: string;
82-
} | null {
83-
const settings = getSettings();
84-
const supabaseUrl = settings.decoSupabaseUrl;
85-
const serviceKey = settings.decoSupabaseServiceKey;
86-
if (!supabaseUrl || !serviceKey) return null;
87-
return { supabaseUrl, serviceKey };
88-
}
89-
90-
async function resolveProfileId(
91-
supabaseUrl: string,
92-
serviceKey: string,
93-
email: string,
94-
): Promise<string | null> {
95-
const profiles = await supabaseGet<{ user_id: string }>(
96-
supabaseUrl,
97-
serviceKey,
98-
`profiles?email=eq.${encodeURIComponent(email)}&select=user_id`,
99-
);
100-
return profiles[0]?.user_id ?? null;
101-
}
102-
103-
async function getOrCreateDecoApiKey(
104-
supabaseUrl: string,
105-
serviceKey: string,
106-
profileId: string,
107-
): Promise<string> {
108-
const existing = await supabaseGet<{ id: string }>(
109-
supabaseUrl,
110-
serviceKey,
111-
`api_key?user_id=eq.${encodeURIComponent(profileId)}&select=id&limit=1`,
112-
);
113-
if (existing[0]?.id) {
114-
return existing[0].id;
115-
}
116-
117-
const created = await supabasePost<{ id: string }>(
118-
supabaseUrl,
119-
serviceKey,
120-
"api_key",
121-
{ user_id: profileId },
122-
);
123-
return created.id;
124-
}
125-
12635
// Require an authenticated user on every handler in this router.
12736
app.use("*", async (c, next) => {
12837
const ctx = c.get("meshContext");
@@ -212,8 +121,6 @@ app.get("/", async (c) => {
212121
}
213122
});
214123

215-
const ADMIN_MCP = "https://sites-admin-mcp.decocache.com/api/mcp";
216-
217124
async function fetchFaviconAsDataUrl(domain: string): Promise<string | null> {
218125
try {
219126
const res = await fetch(`https://${domain}/favicon.ico`, {

0 commit comments

Comments
 (0)