import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("../package.json");
export const VERSION: string = pkg.version;
type ServerConfig = {
baseUrl: string;
apiToken?: string;
cookie?: string;
headers?: Record<string, string>;
graphqlPath: string;
email?: string;
password?: string;
defaultWorkspaceId?: string;
};
/** Config file location: ~/.config/affine-mcp/config */
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;
}
function parseHeadersJson(raw?: string): Record<string, string> | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
console.warn("Failed to parse AFFINE_HEADERS_JSON; expected a JSON object of string headers.");
return undefined;
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== "string") {
console.warn(`Ignoring non-string AFFINE_HEADERS_JSON value for header '${key}'.`);
continue;
}
headers[key] = value;
}
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.`
);
}
return headers;
} catch {
console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
return undefined;
}
}
export function loadConfig(): ServerConfig {
const file = loadConfigFile();
const baseUrl = validateBaseUrl(env("AFFINE_BASE_URL", file, "http://localhost:3010")!);
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 = parseHeadersJson(process.env.AFFINE_HEADERS_JSON);
if (cookie) {
headers = { ...(headers || {}), Cookie: cookie };
}
const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql")!;
const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
return { baseUrl, apiToken, cookie, headers, graphqlPath, email, password, defaultWorkspaceId };
}