import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { createRequire } from "module";
import { EndpointMap } from "./types.js";
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
export const VERSION: string = pkg.version;
export type ServerConfig = {
baseUrl: string;
apiToken?: string;
cookie?: string;
headers?: Record<string, string>;
graphqlPath: string;
email?: string;
password?: string;
defaultWorkspaceId?: string;
endpoints: EndpointMap;
};
const defaultEndpoints: EndpointMap = {
listWorkspaces: { method: "GET", path: "/api/workspaces" },
listDocs: { method: "GET", path: "/api/workspaces/:workspaceId/docs" },
getDoc: { method: "GET", path: "/api/docs/:docId" },
createDoc: { method: "POST", path: "/api/workspaces/:workspaceId/docs" },
updateDoc: { method: "PATCH", path: "/api/docs/:docId" },
deleteDoc: { method: "DELETE", path: "/api/docs/:docId" },
searchDocs: {
method: "GET",
path: "/api/workspaces/:workspaceId/search"
}
};
/** Config file location: ~/.config/affine-mcp/config */
export const CONFIG_DIR = path.join(
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
"affine-mcp"
);
export const CONFIG_FILE = path.join(CONFIG_DIR, "config");
/** Read key=value config file, returns empty object if missing */
export function loadConfigFile(): Record<string, string> {
if (!fs.existsSync(CONFIG_FILE)) return {};
const content = fs.readFileSync(CONFIG_FILE, "utf-8");
const result: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
result[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
return result;
}
/** Write config file atomically with 600 permissions (temp + rename). */
export function writeConfigFile(vars: Record<string, string>) {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
const lines = [
"# Affine MCP Server credentials",
"# Generated by: affine-mcp login",
`# ${new Date().toISOString()}`,
];
for (const [key, value] of Object.entries(vars)) {
if (value) lines.push(`${key}=${value}`);
}
lines.push("");
// Atomic write: write to temp file then rename to prevent partial reads
const tmpFile = path.join(CONFIG_DIR, `.config.tmp.${process.pid}`);
try {
fs.writeFileSync(tmpFile, lines.join("\n"), { mode: 0o600 });
fs.renameSync(tmpFile, CONFIG_FILE);
} catch (err) {
// Clean up temp file on failure
try { fs.unlinkSync(tmpFile); } catch {}
throw err;
}
}
/** Validate and sanitize a base URL. Throws on invalid or dangerous URLs. */
export function validateBaseUrl(input: string): string {
let parsed: URL;
try {
parsed = new URL(input);
} catch {
throw new Error(`Invalid URL: ${input}`);
}
// Reject credentials embedded in URL (SSRF vector)
if (parsed.username || parsed.password) {
throw new Error("URL must not contain embedded credentials (user:pass@host)");
}
// Only allow http and https schemes
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Unsupported URL scheme: ${parsed.protocol} (only http/https allowed)`);
}
// Warn (but allow) plain HTTP for non-local targets
const host = parsed.hostname;
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0";
if (parsed.protocol === "http:" && !isLocal) {
console.error("WARNING: Using plain HTTP for a non-localhost URL. Consider HTTPS for security.");
}
// Return normalized URL without trailing slash
return parsed.origin + parsed.pathname.replace(/\/$/, "");
}
/**
* Helper: read env var with config file fallback.
* Environment variables always take priority over the config file.
*/
function env(name: string, file: Record<string, string>, fallback?: string): string | undefined {
return process.env[name] || file[name] || fallback;
}
export function loadConfig(): ServerConfig {
const file = loadConfigFile();
const baseUrl = env("AFFINE_BASE_URL", file, "http://localhost:3010")!.replace(/\/$/, "");
const apiToken = env("AFFINE_API_TOKEN", file);
const cookie = env("AFFINE_COOKIE", file);
const email = env("AFFINE_EMAIL", file);
const password = env("AFFINE_PASSWORD", file);
let headers: Record<string, string> | undefined = undefined;
const headersJson = process.env.AFFINE_HEADERS_JSON;
if (headersJson) {
try {
headers = JSON.parse(headersJson);
if (headers) {
const sensitiveKeys = Object.keys(headers).filter(
(k) => /^(authorization|cookie)$/i.test(k)
);
if (sensitiveKeys.length) {
console.warn(
`WARNING: AFFINE_HEADERS_JSON contains sensitive key(s): ${sensitiveKeys.join(", ")}. ` +
`These may conflict with built-in auth and are not protected by debug-logging guards.`
);
}
}
} catch (e) {
console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
}
}
if (cookie) {
headers = { ...(headers || {}), Cookie: cookie };
}
const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql")!;
const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
let endpoints = defaultEndpoints;
const endpointsJson = process.env.AFFINE_ENDPOINTS_JSON;
if (endpointsJson) {
try {
endpoints = { ...defaultEndpoints, ...JSON.parse(endpointsJson) } as EndpointMap;
} catch (e) {
console.warn("Failed to parse AFFINE_ENDPOINTS_JSON; using defaults.");
}
}
return { baseUrl, apiToken, cookie, headers, graphqlPath, email, password, defaultWorkspaceId, endpoints };
}