55fb221faa
Each integration card now has a Test button that re-runs its connectivity check on demand and updates the badge in place. - Refactors checkIntegrationStatus into checkIntegration(name) so a single integration can be tested without firing the others. - Adds POST /api/settings/test for the on-demand check. - Replaces the "key exists ⇒ connected" placeholder for Tavily, Gemini, and Nextdoor with real API calls (Tavily search, Gemini models list, Nextdoor advertiser fetch). 401/403 surface as "Invalid API key" / "Token expired or invalid" so a stale credential is obvious instead of pretending to be healthy. - Fixes Postiz auth header (was Bearer, the rest of the codebase passes the API key bare — matches lib/postiz.ts now). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
8.7 KiB
TypeScript
234 lines
8.7 KiB
TypeScript
import { prisma } from "./prisma";
|
|
|
|
// Settings keys and their env var fallbacks
|
|
const SETTINGS_KEYS = {
|
|
// Claude Code CLI authentication (OAuth access token from Claude Max)
|
|
CLAUDE_CODE_OAUTH_TOKEN: { envVar: "CLAUDE_CODE_OAUTH_TOKEN", label: "Claude Code OAuth Token", placeholder: "sk-ant-oat01-...", secret: true },
|
|
|
|
// Postiz
|
|
POSTIZ_URL: { envVar: "POSTIZ_URL", label: "Postiz URL", placeholder: "http://localhost:5000" },
|
|
POSTIZ_API_KEY: { envVar: "POSTIZ_API_KEY", label: "Postiz API Key", placeholder: "your-postiz-api-key", secret: true },
|
|
|
|
// Tavily (Research)
|
|
TAVILY_API_KEY: { envVar: "TAVILY_API_KEY", label: "Tavily API Key", placeholder: "tvly-...", secret: true },
|
|
|
|
// Google Gemini (NanoBanana image generation)
|
|
GEMINI_API_KEY: { envVar: "GEMINI_API_KEY", label: "Google Gemini API Key", placeholder: "AIza...", secret: true },
|
|
|
|
// Nextdoor
|
|
NEXTDOOR_API_TOKEN: { envVar: "NEXTDOOR_API_TOKEN", label: "Nextdoor API Token", placeholder: "your-nextdoor-token", secret: true },
|
|
NEXTDOOR_ADVERTISER_ID: { envVar: "NEXTDOOR_ADVERTISER_ID", label: "Nextdoor Advertiser ID", placeholder: "your-advertiser-id" },
|
|
} as const;
|
|
|
|
export type SettingKey = keyof typeof SETTINGS_KEYS;
|
|
|
|
export const SETTINGS_CONFIG = SETTINGS_KEYS;
|
|
|
|
// Grouped for UI
|
|
export const SETTINGS_GROUPS = [
|
|
{
|
|
name: "Claude",
|
|
description: "OAuth access token for the Claude Code CLI subprocess that drives every pipeline agent. Get one with `claude setup-token` then `security find-generic-password -s 'Claude Code-credentials' -a $(whoami) -w` and use the `claudeAiOauth.accessToken` field. Tokens expire — refresh here when launches start failing with auth errors.",
|
|
docsUrl: "https://docs.claude.com/en/docs/claude-code/setup",
|
|
keys: ["CLAUDE_CODE_OAUTH_TOKEN"] as SettingKey[],
|
|
},
|
|
{
|
|
name: "Postiz",
|
|
description: "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
|
|
docsUrl: "https://postiz.com",
|
|
keys: ["POSTIZ_URL", "POSTIZ_API_KEY"] as SettingKey[],
|
|
},
|
|
{
|
|
name: "Tavily",
|
|
description: "AI-powered web research. Used by the Trend Scout and Research agents. Free tier: 1,000 searches/month.",
|
|
docsUrl: "https://tavily.com",
|
|
keys: ["TAVILY_API_KEY"] as SettingKey[],
|
|
},
|
|
{
|
|
name: "Gemini",
|
|
description: "Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.",
|
|
docsUrl: "https://aistudio.google.com/apikey",
|
|
keys: ["GEMINI_API_KEY"] as SettingKey[],
|
|
},
|
|
{
|
|
name: "Nextdoor",
|
|
description: "Direct Nextdoor Ads API integration for local advertising.",
|
|
docsUrl: "https://developer.nextdoor.com",
|
|
keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"] as SettingKey[],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Get a setting value. Checks DB first, falls back to env var.
|
|
*/
|
|
export async function getSetting(key: SettingKey): Promise<string> {
|
|
try {
|
|
const setting = await prisma.setting.findUnique({ where: { key } });
|
|
if (setting?.value) return setting.value;
|
|
} catch {
|
|
// DB not available, fall through to env
|
|
}
|
|
|
|
const config = SETTINGS_KEYS[key];
|
|
return process.env[config.envVar] || "";
|
|
}
|
|
|
|
/**
|
|
* Get all settings as a map.
|
|
*/
|
|
export async function getAllSettings(): Promise<Record<string, string>> {
|
|
const result: Record<string, string> = {};
|
|
|
|
for (const [key, config] of Object.entries(SETTINGS_KEYS)) {
|
|
try {
|
|
const setting = await prisma.setting.findUnique({ where: { key } });
|
|
result[key] = setting?.value || process.env[config.envVar] || "";
|
|
} catch {
|
|
result[key] = process.env[config.envVar] || "";
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Save a setting to the database.
|
|
*/
|
|
export async function saveSetting(key: SettingKey, value: string) {
|
|
await prisma.setting.upsert({
|
|
where: { key },
|
|
update: { value },
|
|
create: { key, value },
|
|
});
|
|
}
|
|
|
|
export type IntegrationName = "claude" | "postiz" | "tavily" | "gemini" | "nextdoor";
|
|
export type IntegrationStatus = { connected: boolean; error?: string };
|
|
|
|
function errorMessage(e: unknown): string {
|
|
if (e instanceof Error) {
|
|
if (e.name === "TimeoutError" || e.name === "AbortError") return "Timed out";
|
|
return e.message;
|
|
}
|
|
return "Connection failed";
|
|
}
|
|
|
|
/**
|
|
* Live-test a single integration by making a real API call.
|
|
*/
|
|
export async function checkIntegration(name: IntegrationName): Promise<IntegrationStatus> {
|
|
const settings = await getAllSettings();
|
|
|
|
switch (name) {
|
|
case "claude": {
|
|
// OAuth token — validate by hitting the messages API with a 1-token call.
|
|
if (!settings.CLAUDE_CODE_OAUTH_TOKEN) return { connected: false, error: "Not configured" };
|
|
try {
|
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"anthropic-version": "2023-06-01",
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
Authorization: `Bearer ${settings.CLAUDE_CODE_OAUTH_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: "claude-haiku-4-5-20251001",
|
|
max_tokens: 1,
|
|
messages: [{ role: "user", content: "." }],
|
|
}),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (res.ok) return { connected: true };
|
|
if (res.status === 401) return { connected: false, error: "Token expired or invalid" };
|
|
return { connected: false, error: `HTTP ${res.status}` };
|
|
} catch (e) {
|
|
return { connected: false, error: errorMessage(e) };
|
|
}
|
|
}
|
|
|
|
case "postiz": {
|
|
if (!settings.POSTIZ_URL || !settings.POSTIZ_API_KEY) return { connected: false, error: "Not configured" };
|
|
try {
|
|
const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, {
|
|
headers: { Authorization: settings.POSTIZ_API_KEY },
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (res.ok) return { connected: true };
|
|
return { connected: false, error: `HTTP ${res.status}` };
|
|
} catch (e) {
|
|
return { connected: false, error: errorMessage(e) };
|
|
}
|
|
}
|
|
|
|
case "tavily": {
|
|
if (!settings.TAVILY_API_KEY) return { connected: false, error: "Not configured" };
|
|
try {
|
|
const res = await fetch("https://api.tavily.com/search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
api_key: settings.TAVILY_API_KEY,
|
|
query: "ping",
|
|
max_results: 1,
|
|
}),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
if (res.ok) return { connected: true };
|
|
if (res.status === 401 || res.status === 403) return { connected: false, error: "Invalid API key" };
|
|
return { connected: false, error: `HTTP ${res.status}` };
|
|
} catch (e) {
|
|
return { connected: false, error: errorMessage(e) };
|
|
}
|
|
}
|
|
|
|
case "gemini": {
|
|
if (!settings.GEMINI_API_KEY) return { connected: false, error: "Not configured" };
|
|
try {
|
|
const res = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(settings.GEMINI_API_KEY)}`,
|
|
{ signal: AbortSignal.timeout(8000) }
|
|
);
|
|
if (res.ok) return { connected: true };
|
|
if (res.status === 400 || res.status === 403) return { connected: false, error: "Invalid API key" };
|
|
return { connected: false, error: `HTTP ${res.status}` };
|
|
} catch (e) {
|
|
return { connected: false, error: errorMessage(e) };
|
|
}
|
|
}
|
|
|
|
case "nextdoor": {
|
|
if (!settings.NEXTDOOR_API_TOKEN || !settings.NEXTDOOR_ADVERTISER_ID) {
|
|
return { connected: false, error: "Not configured" };
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`https://api.nextdoor.com/v1/advertiser/${encodeURIComponent(settings.NEXTDOOR_ADVERTISER_ID)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${settings.NEXTDOOR_API_TOKEN}` },
|
|
signal: AbortSignal.timeout(8000),
|
|
}
|
|
);
|
|
if (res.ok) return { connected: true };
|
|
if (res.status === 401 || res.status === 403) return { connected: false, error: "Token expired or invalid" };
|
|
if (res.status === 404) return { connected: false, error: "Advertiser ID not found" };
|
|
return { connected: false, error: `HTTP ${res.status}` };
|
|
} catch (e) {
|
|
return { connected: false, error: errorMessage(e) };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const ALL_INTEGRATIONS: IntegrationName[] = ["claude", "postiz", "tavily", "gemini", "nextdoor"];
|
|
|
|
/**
|
|
* Check connectivity status for every integration in parallel.
|
|
*/
|
|
export async function checkIntegrationStatus(): Promise<Record<string, IntegrationStatus>> {
|
|
const results = await Promise.all(
|
|
ALL_INTEGRATIONS.map(async (name) => [name, await checkIntegration(name)] as const)
|
|
);
|
|
return Object.fromEntries(results);
|
|
}
|