63698333ff
The OAuth token that authenticates the spawned `claude` CLI was only readable from the container env, so an expired token meant editing .env on the Unraid host and rebuilding. Now it can be rotated from the Settings page like every other key. - Adds CLAUDE_CODE_OAUTH_TOKEN to the settings registry and a "Claude" card at the top of the settings UI. - loadPipelineEnv() injects the DB value into every spawned subprocess env (overrides the container env), covering both campaign launches and chat sessions. - checkIntegrationStatus() validates the token by hitting the Anthropic messages API with a 1-token call, surfacing 401s as "Token expired or invalid" instead of a generic "Not connected". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
6.5 KiB
TypeScript
184 lines
6.5 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 },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check connectivity status for each integration.
|
|
*/
|
|
export async function checkIntegrationStatus(): Promise<Record<string, { connected: boolean; error?: string }>> {
|
|
const settings = await getAllSettings();
|
|
const status: Record<string, { connected: boolean; error?: string }> = {};
|
|
|
|
// Claude OAuth token — validate by hitting the messages API with a 1-token call.
|
|
// A valid OAuth token returns 200; an expired/invalid one returns 401.
|
|
if (settings.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
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) {
|
|
status.claude = { connected: true };
|
|
} else if (res.status === 401) {
|
|
status.claude = { connected: false, error: "Token expired or invalid" };
|
|
} else {
|
|
status.claude = { connected: false, error: `HTTP ${res.status}` };
|
|
}
|
|
} catch (e) {
|
|
status.claude = { connected: false, error: e instanceof Error ? e.message : "Connection failed" };
|
|
}
|
|
} else {
|
|
status.claude = { connected: false, error: "Not configured" };
|
|
}
|
|
|
|
// Postiz
|
|
if (settings.POSTIZ_URL && settings.POSTIZ_API_KEY) {
|
|
try {
|
|
const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, {
|
|
headers: { Authorization: `Bearer ${settings.POSTIZ_API_KEY}` },
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
status.postiz = { connected: res.ok };
|
|
if (!res.ok) status.postiz.error = `HTTP ${res.status}`;
|
|
} catch (e) {
|
|
status.postiz = { connected: false, error: e instanceof Error ? e.message : "Connection failed" };
|
|
}
|
|
} else {
|
|
status.postiz = { connected: false, error: "Not configured" };
|
|
}
|
|
|
|
// Tavily
|
|
if (settings.TAVILY_API_KEY) {
|
|
status.tavily = { connected: true }; // No ping endpoint, just check if key exists
|
|
} else {
|
|
status.tavily = { connected: false, error: "Not configured" };
|
|
}
|
|
|
|
// Gemini
|
|
if (settings.GEMINI_API_KEY) {
|
|
status.gemini = { connected: true };
|
|
} else {
|
|
status.gemini = { connected: false, error: "Not configured" };
|
|
}
|
|
|
|
// Nextdoor
|
|
if (settings.NEXTDOOR_API_TOKEN && settings.NEXTDOOR_ADVERTISER_ID) {
|
|
status.nextdoor = { connected: true };
|
|
} else {
|
|
status.nextdoor = { connected: false, error: "Not configured" };
|
|
}
|
|
|
|
return status;
|
|
}
|