mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
Add Azure Connector Namespaces canvas extension 🤖🤖🤖 (#2250)
* Add MCP Connectors (connector-namespaces) canvas extension A Copilot CLI canvas extension for browsing and adding MCP connectors from an Azure Connector Namespace into a Copilot session. Sign-in is dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public client, loopback redirect); network access is restricted to the public Azure Resource Manager endpoint. MIT licensed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize connector icon brand colors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Connector Namespace playground actions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Replace sandbox skill with native tool Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Clarify connector disconnect action Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Color disconnect action red Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Add connector extension plugin manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address connector canvas review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Remove legacy connector canvas manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address remaining connector review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector convergence Address the latest connector review batch across config persistence, executable trust, reauthentication, JSON-RPC transport, smoke safety, and accessibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Use native HTTP connector configs Persist Connector Namespace MCP servers as direct HTTPS entries with API-key headers, remove the stdio unwrap proxy, and exercise the native Streamable HTTP path in smoke coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Secure persisted connector state Create the Connector Namespace artifacts directory and saved gateway config with private permissions, and align the reduced-motion regression notes with the static fallback behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf --------- Co-authored-by: Alex Yang <yangalex@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
// Loopback HTTP server — serves connector namespace picker + connector catalog UI.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { renderCatalogHtml, renderErrorHtml, renderSetupHtml } from "./renderer.mjs";
|
||||
import { renderCreateNamespaceHtml } from "./createPage.mjs";
|
||||
import { addConnector, removeConnector, saveConfig, clearConfig } from "./state.mjs";
|
||||
import { fetchCatalog, invalidateCache } from "./catalog.mjs";
|
||||
import {
|
||||
listConnectorGateways,
|
||||
listSubscriptions,
|
||||
listResourceGroups,
|
||||
listUserAssignedIdentities,
|
||||
checkConnectorGatewayNameAvailable,
|
||||
createResourceGroup,
|
||||
createConnectorGateway,
|
||||
buildGatewayIdentity,
|
||||
} from "./armClient.mjs";
|
||||
import { installConnector, finishInstall, reauthConnector, finishReauth, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, removeLocalEntry, deleteConnection, prewarmMeta } from "./install.mjs";
|
||||
|
||||
const servers = new Map();
|
||||
const starting = new Map(); // instanceId → Promise<entry> while a server is binding
|
||||
const gatewayCache = new Map();
|
||||
const pendingOAuth = new Map(); // connName → timestamp
|
||||
|
||||
const PENDING_OAUTH_TTL_MS = 15 * 60 * 1000;
|
||||
const MUTATION_REPLAY_TTL_MS = 15 * 60 * 1000;
|
||||
const CAPABILITY_TOKEN_HEADER = "x-connector-namespace-token";
|
||||
const MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
||||
const REQUEST_ID = /^[0-9a-f]{32}$/;
|
||||
|
||||
class RequestBodyTooLargeError extends Error {}
|
||||
|
||||
function createCapabilityToken() {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function isCanonicalHost(req, canonicalHost) {
|
||||
return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
|
||||
}
|
||||
|
||||
export function requiresCapabilityToken(pathname) {
|
||||
return pathname.startsWith("/api/") || pathname === "/oauth-status" || pathname.startsWith("/auth/callback/");
|
||||
}
|
||||
|
||||
export function hasCapabilityToken(req, url, expectedToken) {
|
||||
if (!expectedToken) {
|
||||
return false;
|
||||
}
|
||||
const header = req.headers[CAPABILITY_TOKEN_HEADER];
|
||||
const headerToken = Array.isArray(header) ? header[0] : header;
|
||||
const callbackToken = url.pathname.startsWith("/auth/callback/")
|
||||
? url.searchParams.get("cn_token")
|
||||
: null;
|
||||
return headerToken === expectedToken || callbackToken === expectedToken;
|
||||
}
|
||||
|
||||
function rejectForbidden(res, error) {
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
// Drop stale/abandoned consent markers so the map can't grow unbounded and a
|
||||
// brand-new install of the same connection can't observe a stale "done".
|
||||
function prunePendingOAuth() {
|
||||
const now = Date.now();
|
||||
for (const [name, ts] of pendingOAuth) {
|
||||
if (now - ts > PENDING_OAUTH_TTL_MS) pendingOAuth.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
export function runIdempotentOperation(operations, key, start, now = Date.now()) {
|
||||
for (const [id, operation] of operations) {
|
||||
if (operation.expiresAt <= now) operations.delete(id);
|
||||
}
|
||||
const existing = operations.get(key);
|
||||
if (existing) return existing.promise;
|
||||
const promise = Promise.resolve().then(start);
|
||||
operations.set(key, { promise, expiresAt: now + MUTATION_REPLAY_TTL_MS });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Whether a connector was added during the life of THIS extension process.
|
||||
// MCP tools are only loaded by the CLI at session start, so an install done
|
||||
// after the process started isn't usable until the session restarts. A real
|
||||
// session restart spawns a fresh process and resets this to false. `acked`
|
||||
// lets the user dismiss the reminder for the rest of the process.
|
||||
let pendingRestart = false;
|
||||
let restartAcked = false;
|
||||
|
||||
export function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
let size = 0;
|
||||
let settled = false;
|
||||
req.on("data", (chunk) => {
|
||||
if (settled) return;
|
||||
size += Buffer.byteLength(chunk);
|
||||
if (size > MAX_REQUEST_BODY_BYTES) {
|
||||
settled = true;
|
||||
data = "";
|
||||
reject(new RequestBodyTooLargeError());
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Blocks cross-site POSTs to /api/* so a random web page can't drive the user's
|
||||
// ARM operations through this loopback server (CSRF). Returns true only when the
|
||||
// request carries an explicit foreign-origin signal: an Origin header from a
|
||||
// different http(s) origin than the canonical loopback URL, an opaque `null`
|
||||
// origin (sandboxed iframe / data: or blob: URI), or a Fetch-Metadata marker of
|
||||
// cross-site/same-site. The panel loads as a top-level http document, so its own
|
||||
// fetches are same-origin (Origin === our canonical loopback origin, never
|
||||
// "null"), and header-less callers (the node test harness, non-browser clients)
|
||||
// fall through as allowed, so nothing legit breaks.
|
||||
export function isCrossSiteRequest(req, canonicalOrigin) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
if (origin === canonicalOrigin) return false; // our own loopback UI
|
||||
if (origin === "null") return true; // opaque origin: sandboxed iframe / data: or blob: URI
|
||||
if (/^https?:\/\//i.test(origin)) return true; // a different web page
|
||||
return false; // non-web scheme (host webview)
|
||||
}
|
||||
const site = req.headers["sec-fetch-site"];
|
||||
return site === "cross-site" || site === "same-site";
|
||||
}
|
||||
|
||||
async function handleRequest(req, res, instanceId, serverEntry) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
|
||||
if (!isCanonicalHost(req, serverEntry.host)) {
|
||||
rejectForbidden(res, "host_not_allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresCapabilityToken(url.pathname) && !hasCapabilityToken(req, url, serverEntry.token)) {
|
||||
rejectForbidden(res, "missing_or_invalid_capability_token");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject cross-site attempts to invoke state-changing API routes (CSRF).
|
||||
// Only POST /api/* is gated: GET navigations like the OAuth callback and the
|
||||
// read routes are never blocked here.
|
||||
if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req, serverEntry.origin)) {
|
||||
rejectForbidden(res, "cross_site_blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- API routes ---
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/add") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { added: false, reason: "no_config" }); return; }
|
||||
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
|
||||
const connector = catalog.find((c) => c.id === body.id);
|
||||
json(res, connector ? addConnector(connector) : { added: false, reason: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/remove") {
|
||||
const body = await parseBody(req);
|
||||
json(res, removeConnector(body.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/select-gateway") {
|
||||
const body = await parseBody(req);
|
||||
const { subscriptionId, resourceGroup, gatewayName } = body;
|
||||
if (!subscriptionId || !resourceGroup || !gatewayName) {
|
||||
json(res, { error: "missing_fields" });
|
||||
return;
|
||||
}
|
||||
const config = { subscriptionId, resourceGroup, gatewayName };
|
||||
serverEntry.config = config;
|
||||
saveConfig(config);
|
||||
invalidateCache();
|
||||
json(res, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/change-gateway") {
|
||||
try {
|
||||
clearConfig();
|
||||
serverEntry.config = null;
|
||||
invalidateCache();
|
||||
json(res, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/gateways") {
|
||||
const subId = url.searchParams.get("subscriptionId");
|
||||
const all = url.searchParams.get("all") === "true";
|
||||
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
|
||||
const cacheKey = `gw:${subId}:${all ? "all" : "top"}`;
|
||||
if (gatewayCache.has(cacheKey)) {
|
||||
const cached = gatewayCache.get(cacheKey);
|
||||
json(res, { gateways: cached.items, hasMore: cached.hasMore, cached: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await listConnectorGateways(subId, { fetchAll: all });
|
||||
gatewayCache.set(cacheKey, result);
|
||||
json(res, { gateways: result.items, hasMore: result.hasMore });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Create connector namespace routes ---
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/resource-groups") {
|
||||
const subId = url.searchParams.get("subscriptionId");
|
||||
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
|
||||
try {
|
||||
json(res, { resourceGroups: await listResourceGroups(subId) });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/identities") {
|
||||
const subId = url.searchParams.get("subscriptionId");
|
||||
if (!subId) { json(res, { error: "missing subscriptionId" }); return; }
|
||||
try {
|
||||
json(res, { identities: await listUserAssignedIdentities(subId) });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/check-name") {
|
||||
const subId = url.searchParams.get("subscriptionId");
|
||||
const resourceGroup = url.searchParams.get("resourceGroup");
|
||||
const name = url.searchParams.get("name");
|
||||
if (!subId || !resourceGroup || !name) { json(res, { error: "missing_fields" }); return; }
|
||||
try {
|
||||
json(res, { available: await checkConnectorGatewayNameAvailable(subId, resourceGroup, name) });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/create-namespace") {
|
||||
const body = await parseBody(req);
|
||||
const { subscriptionId, resourceGroup, createNewResourceGroup, region, name, enableSystemIdentity, userAssignedIds } = body;
|
||||
if (!subscriptionId || !resourceGroup || !region || !name) {
|
||||
json(res, { error: "missing_fields" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (createNewResourceGroup) {
|
||||
await createResourceGroup(subscriptionId, resourceGroup, region);
|
||||
}
|
||||
const identity = buildGatewayIdentity(!!enableSystemIdentity, Array.isArray(userAssignedIds) ? userAssignedIds : []);
|
||||
await createConnectorGateway(subscriptionId, resourceGroup, name, { location: region, identity });
|
||||
const config = { subscriptionId, resourceGroup, gatewayName: name };
|
||||
serverEntry.config = config;
|
||||
saveConfig(config);
|
||||
invalidateCache();
|
||||
gatewayCache.clear();
|
||||
json(res, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Install flow routes ---
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/install") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
const { apiName, displayName, requestId } = body;
|
||||
if (!apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
|
||||
const port = new URL(serverEntry.url).port;
|
||||
const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
|
||||
try {
|
||||
const result = await runIdempotentOperation(
|
||||
serverEntry.operations,
|
||||
`install:${requestId}`,
|
||||
() => installConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
|
||||
);
|
||||
if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/reauth") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
const { apiName, displayName, requestId } = body;
|
||||
if (!apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
if (!REQUEST_ID.test(requestId || "")) { json(res, { error: "invalid requestId" }); return; }
|
||||
const port = new URL(serverEntry.url).port;
|
||||
const callbackBase = `http://127.0.0.1:${port}/auth/callback/`;
|
||||
try {
|
||||
const result = await runIdempotentOperation(
|
||||
serverEntry.operations,
|
||||
`reauth:${requestId}`,
|
||||
() => reauthConnector(config, apiName, displayName || apiName, callbackBase, "profile", serverEntry.token),
|
||||
);
|
||||
if (result && !result.error && !result.needsConsent) { pendingRestart = true; restartAcked = false; }
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/finish-install") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
if (!body.connName) { json(res, { error: "missing connName" }); return; }
|
||||
try {
|
||||
const result = await finishInstall(config, body.apiName, body.displayName, body.connName, body.location, "profile");
|
||||
if (result && !result.error) { pendingRestart = true; restartAcked = false; }
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/finish-reauth") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
if (!body.connName) { json(res, { error: "missing connName" }); return; }
|
||||
try {
|
||||
// configName may be absent when reauth fell back to a fresh install
|
||||
// (stored connection was gone); finishReauth handles that defensively.
|
||||
const result = await finishReauth(config, body.apiName, body.displayName, body.connName, body.configName, body.location, "profile");
|
||||
if (result && !result.error) { pendingRestart = true; restartAcked = false; }
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/open-url") {
|
||||
const body = await parseBody(req);
|
||||
if (!body.url || !/^https?:\/\//.test(body.url)) { json(res, { error: "invalid url" }); return; }
|
||||
try {
|
||||
await openInBrowser(body.url);
|
||||
json(res, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/open-config") {
|
||||
try {
|
||||
const result = await openMcpConfigFile();
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { ok: false, error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/oauth-status") {
|
||||
prunePendingOAuth();
|
||||
const connName = url.searchParams.get("connectionName") || "";
|
||||
// Consume the marker once observed so the map self-cleans on the happy
|
||||
// path instead of lingering until the TTL sweep. delete() returns true
|
||||
// iff the marker existed, which is exactly the "done" signal.
|
||||
const done = connName ? pendingOAuth.delete(connName) : false;
|
||||
json(res, { done });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
try {
|
||||
const state = await getInstalledState(config);
|
||||
json(res, { state, pendingRestart: pendingRestart && !restartAcked });
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/ack-restart") {
|
||||
restartAcked = true;
|
||||
json(res, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/uninstall") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
try {
|
||||
const result = await uninstallConnector(config, body.apiName);
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Local-only remove: unwire the connector from Copilot's mcp config without
|
||||
// deleting anything on the namespace. Mirrors /api/uninstall but calls the
|
||||
// local-only primitive.
|
||||
if (req.method === "POST" && url.pathname === "/api/remove-local") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
if (!body.apiName) { json(res, { error: "missing apiName" }); return; }
|
||||
try {
|
||||
const result = await removeLocalEntry(config, body.apiName);
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Roll back a connection orphaned by a cancelled install (no config exists
|
||||
// yet, so /api/uninstall can't find it — delete the connection directly).
|
||||
if (req.method === "POST" && url.pathname === "/api/rollback-connection") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
if (!config) { json(res, { error: "no_config" }); return; }
|
||||
if (!body.connName) { json(res, { error: "missing connName" }); return; }
|
||||
try {
|
||||
const result = await deleteConnection(config, body.connName);
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
json(res, { error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/auth/callback/")) {
|
||||
const connName = decodeURIComponent(url.pathname.slice("/auth/callback/".length));
|
||||
prunePendingOAuth();
|
||||
if (connName) pendingOAuth.set(connName, Date.now());
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(`<!doctype html><html><head><meta charset="utf-8"><title>Sign-in complete</title></head><body style="font-family:system-ui;padding:2rem;"><h2>Sign-in complete</h2><p>You can close this tab and return to Copilot.</p></body></html>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Page routes ---
|
||||
|
||||
const config = serverEntry.config;
|
||||
|
||||
// Create connector namespace page (reachable with or without a saved config)
|
||||
if (url.pathname === "/create") {
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
const preselected = url.searchParams.get("subscriptionId") || "";
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token));
|
||||
} catch (err) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup page (no gateway configured)
|
||||
if (!config || url.pathname === "/setup") {
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderSetupHtml(subs, "", serverEntry.token));
|
||||
} catch (err) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Catalog page
|
||||
const filter = url.searchParams.get("filter") || "";
|
||||
const category = url.searchParams.get("category") || "";
|
||||
const source = url.searchParams.get("source") || "";
|
||||
|
||||
try {
|
||||
const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName);
|
||||
// Warm connector metadata (opId + connection params) in the background so
|
||||
// the Connect click doesn't pay for the slow swagger export.
|
||||
prewarmMeta(config, catalog.map((c) => c.apiName));
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token));
|
||||
} catch (err) {
|
||||
// Trust-and-fallback: a saved namespace that can no longer be read
|
||||
// (deleted, access revoked, a transient outage) should drop the user
|
||||
// back to the picker, not a dead-end error page. Only if even the
|
||||
// picker can't load its subscriptions do we surface the raw error.
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderSetupHtml(subs, `couldn't open namespace ${config.gatewayName} .. pick another to continue.`, serverEntry.token));
|
||||
} catch (subErr) {
|
||||
// Both the namespace catalog and the subscription list failed. Surface
|
||||
// the subscription error (the reason the picker itself can't render) and
|
||||
// keep the original namespace error for context.
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`couldn't load subscriptions: ${subErr.message} .. opening namespace ${config.gatewayName} also failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function json(res, data) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function getServerConfig(instanceId) {
|
||||
return servers.get(instanceId)?.config ?? null;
|
||||
}
|
||||
|
||||
export function listenOnLoopback(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error) => reject(error);
|
||||
server.once("error", onError);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.removeListener("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function startServer(instanceId, { config, defaultConfig } = {}) {
|
||||
const existing = servers.get(instanceId);
|
||||
if (existing) {
|
||||
if (config !== undefined) existing.config = config;
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
const inflight = starting.get(instanceId);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const p = (async () => {
|
||||
const entry = {
|
||||
server: null,
|
||||
url: "",
|
||||
host: "",
|
||||
origin: "",
|
||||
token: createCapabilityToken(),
|
||||
config: config ?? defaultConfig ?? null,
|
||||
operations: new Map(),
|
||||
};
|
||||
const server = createServer((req, res) => {
|
||||
handleRequest(req, res, instanceId, entry).catch((err) => {
|
||||
if (res.writableEnded) return;
|
||||
res.statusCode = err instanceof RequestBodyTooLargeError ? 413 : 500;
|
||||
if (!(err instanceof RequestBodyTooLargeError)) {
|
||||
console.error("[connector-namespaces] request failed:", err);
|
||||
}
|
||||
json(res, { error: err instanceof RequestBodyTooLargeError ? "request_body_too_large" : "internal_error" });
|
||||
});
|
||||
});
|
||||
entry.server = server;
|
||||
await listenOnLoopback(server);
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
entry.host = `127.0.0.1:${port}`;
|
||||
entry.origin = `http://${entry.host}`;
|
||||
entry.url = `${entry.origin}/`;
|
||||
servers.set(instanceId, entry);
|
||||
return entry;
|
||||
})();
|
||||
// Record the in-flight start synchronously so a concurrent open() for the
|
||||
// same instance awaits this server instead of binding a second one and
|
||||
// leaking the first.
|
||||
starting.set(instanceId, p);
|
||||
const clearStarting = () => { if (starting.get(instanceId) === p) starting.delete(instanceId); };
|
||||
p.then(clearStarting, clearStarting);
|
||||
return p;
|
||||
}
|
||||
|
||||
export async function stopServer(instanceId) {
|
||||
const inflight = starting.get(instanceId);
|
||||
if (inflight) { try { await inflight; } catch { /* start failed; nothing to close */ } }
|
||||
const entry = servers.get(instanceId);
|
||||
if (entry) {
|
||||
servers.delete(instanceId);
|
||||
// close() never resolves while the iframe holds a keep-alive socket —
|
||||
// drop live connections first so onClose can't hang and leak the process.
|
||||
if (typeof entry.server.closeAllConnections === "function") entry.server.closeAllConnections();
|
||||
await new Promise((resolve) => entry.server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user