Skip to content
Open
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
27 changes: 27 additions & 0 deletions apps/mesh/migrations/063-github-credentials.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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<unknown>): Promise<void> {
await db.schema.dropTable("github_credentials").execute();
}
31 changes: 31 additions & 0 deletions apps/mesh/migrations/064-github-installation-id.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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<unknown>): Promise<void> {
await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute(
db,
);
Comment on lines +23 to +25
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: down() can fail because it sets access_token back to NOT NULL without first handling rows that now contain NULL.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/migrations/059-github-installation-id.ts, line 23:

<comment>`down()` can fail because it sets `access_token` back to NOT NULL without first handling rows that now contain NULL.</comment>

<file context>
@@ -0,0 +1,31 @@
+}
+
+export async function down(db: Kysely<unknown>): Promise<void> {
+  await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute(
+    db,
+  );
</file context>
Suggested change
await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute(
db,
);
await sql`UPDATE github_credentials SET access_token = '' WHERE access_token IS NULL`.execute(db);
await sql`ALTER TABLE github_credentials ALTER COLUMN access_token SET NOT NULL`.execute(
db,
);
Fix with Cubic


await db.schema
.alterTable("github_credentials")
.dropColumn("installation_id")
.execute();
}
4 changes: 4 additions & 0 deletions apps/mesh/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -135,6 +137,8 @@ const migrations: Record<string, Migration> = {
"061-downstream-token-connection-index":
migration061downstreamtokenconnectionindex,
"062-private-registry": migration062privateregistry,
"063-github-credentials": migration063githubcredentials,
"064-github-installation-id": migration064githubinstallationid,
};

export default migrations;
4 changes: 4 additions & 0 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
107 changes: 7 additions & 100 deletions apps/mesh/src/api/routes/deco-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -25,104 +32,6 @@ interface SupabaseSite {
thumb_url: string | null;
}

async function supabaseGet<T>(
supabaseUrl: string,
serviceKey: string,
path: string,
): Promise<T[]> {
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<T[]>;
}

async function supabasePost<T>(
supabaseUrl: string,
serviceKey: string,
table: string,
body: Record<string, unknown>,
): Promise<T> {
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<string | null> {
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<string> {
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");
Expand Down Expand Up @@ -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<string | null> {
try {
const res = await fetch(`https://${domain}/favicon.ico`, {
Expand Down
Loading
Loading