singularity-forge/web/app/api/remote-questions/route.ts
2026-05-05 14:46:18 +02:00

471 lines
14 KiB
TypeScript

import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// ─── Constants (replicated from extensions — cannot import due to Turbopack constraint) ───
type RemoteChannel = "slack" | "discord" | "telegram";
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
slack: /^[A-Z0-9]{9,12}$/,
discord: /^\d{17,20}$/,
telegram: /^-?\d{5,20}$/,
};
const ENV_KEYS: Record<RemoteChannel, string> = {
slack: "SLACK_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
telegram: "TELEGRAM_BOT_TOKEN",
};
const DEFAULT_TIMEOUT_MINUTES = 5;
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
const MIN_TIMEOUT_MINUTES = 1;
const MAX_TIMEOUT_MINUTES = 30;
const MIN_POLL_INTERVAL_SECONDS = 2;
const MAX_POLL_INTERVAL_SECONDS = 30;
const VALID_CHANNELS: readonly RemoteChannel[] = [
"slack",
"discord",
"telegram",
] as const;
// Map channel → auth.json provider ID (matches key-manager.ts PROVIDER_REGISTRY)
const AUTH_PROVIDER_IDS: Record<RemoteChannel, string> = {
slack: "slack_bot",
discord: "discord_bot",
telegram: "telegram_bot",
};
// ─── Auth.json Helpers ────────────────────────────────────────────────────────
function getAuthPath(): string {
return join(homedir(), ".sf", "agent", "auth.json");
}
function readAuthData(): Record<string, unknown> {
const authPath = getAuthPath();
if (!existsSync(authPath)) return {};
try {
const content = readFileSync(authPath, "utf-8");
const parsed = JSON.parse(content);
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function writeAuthData(data: Record<string, unknown>): void {
const authPath = getAuthPath();
const parentDir = dirname(authPath);
if (!existsSync(parentDir))
mkdirSync(parentDir, { recursive: true, mode: 0o700 });
writeFileSync(authPath, JSON.stringify(data, null, 2), "utf-8");
chmodSync(authPath, 0o600);
}
function hasStoredBotToken(channel: RemoteChannel): boolean {
const data = readAuthData();
const providerId = AUTH_PROVIDER_IDS[channel];
const entry = data[providerId];
if (!entry) return false;
// Could be a single credential or an array
const creds = Array.isArray(entry) ? entry : [entry];
return creds.some((c: unknown) => {
if (typeof c !== "object" || c === null) return false;
const cred = c as Record<string, unknown>;
return (
cred.type === "api_key" &&
typeof cred.key === "string" &&
cred.key.length > 0
);
});
}
function maskToken(token: string): string {
if (token.length <= 8) return token.slice(0, 2) + "***" + token.slice(-2);
return token.slice(0, 4) + "***" + token.slice(-4);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getPreferencesPath(): string {
return join(homedir(), ".sf", "PREFERENCES.md");
}
function clamp(
value: number | undefined,
defaultVal: number,
min: number,
max: number,
): number {
const v =
typeof value === "number" && Number.isFinite(value) ? value : defaultVal;
return Math.max(min, Math.min(max, v));
}
function isValidChannel(ch: unknown): ch is RemoteChannel {
return (
typeof ch === "string" && (VALID_CHANNELS as readonly string[]).includes(ch)
);
}
/**
* Parse YAML frontmatter from a markdown file.
* Uses the same indexOf-based approach as parsePreferencesMarkdown() in preferences.ts.
*/
function parseFrontmatter(content: string): {
data: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
} {
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
if (!content.startsWith(startMarker)) {
return { data: {}, body: content, hasFrontmatter: false };
}
const searchStart = startMarker.length;
const endIdx = content.indexOf("\n---", searchStart);
if (endIdx === -1) {
return { data: {}, body: content, hasFrontmatter: false };
}
const block = content.slice(searchStart, endIdx);
const afterFrontmatter = content.slice(endIdx + 4); // skip \n---
try {
const parsed = parseYaml(block.replace(/\r/g, ""));
const data =
typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
return { data, body: afterFrontmatter, hasFrontmatter: true };
} catch {
return { data: {}, body: content, hasFrontmatter: false };
}
}
/**
* Write frontmatter data back to a markdown file, preserving the body content.
*/
function writeFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlStr = stringifyYaml(data, { lineWidth: 0 }).trimEnd();
return `---\n${yamlStr}\n---${body}`;
}
interface RemoteQuestionsResponse {
config: {
channel: RemoteChannel;
channelId: string;
timeoutMinutes: number;
pollIntervalSeconds: number;
} | null;
envVarSet: boolean;
tokenSet: boolean;
envVarName: string | null;
status: string;
}
// ─── GET ──────────────────────────────────────────────────────────────────────
export async function GET(): Promise<Response> {
try {
const prefsPath = getPreferencesPath();
if (!existsSync(prefsPath)) {
const response: RemoteQuestionsResponse = {
config: null,
envVarSet: false,
tokenSet: false,
envVarName: null,
status: "not_configured",
};
return Response.json(response, {
headers: { "Cache-Control": "no-store" },
});
}
const content = readFileSync(prefsPath, "utf-8");
const { data } = parseFrontmatter(content);
const rq = data.remote_questions as Record<string, unknown> | undefined;
if (!rq || typeof rq !== "object" || !rq.channel) {
const response: RemoteQuestionsResponse = {
config: null,
envVarSet: false,
tokenSet: false,
envVarName: null,
status: "not_configured",
};
return Response.json(response, {
headers: { "Cache-Control": "no-store" },
});
}
const channel = rq.channel as string;
if (!isValidChannel(channel)) {
const response: RemoteQuestionsResponse = {
config: null,
envVarSet: false,
tokenSet: false,
envVarName: null,
status: "invalid_channel",
};
return Response.json(response, {
headers: { "Cache-Control": "no-store" },
});
}
const channelId = rq.channel_id != null ? String(rq.channel_id) : "";
const timeoutMinutes = clamp(
rq.timeout_minutes as number | undefined,
DEFAULT_TIMEOUT_MINUTES,
MIN_TIMEOUT_MINUTES,
MAX_TIMEOUT_MINUTES,
);
const pollIntervalSeconds = clamp(
rq.poll_interval_seconds as number | undefined,
DEFAULT_POLL_INTERVAL_SECONDS,
MIN_POLL_INTERVAL_SECONDS,
MAX_POLL_INTERVAL_SECONDS,
);
const envVarName = ENV_KEYS[channel];
const envVarSet = !!process.env[envVarName];
const tokenSet = hasStoredBotToken(channel) || envVarSet;
const response: RemoteQuestionsResponse = {
config: {
channel,
channelId,
timeoutMinutes,
pollIntervalSeconds,
},
envVarSet,
tokenSet,
envVarName,
status: "configured",
};
return Response.json(response, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Response.json(
{ error: `Failed to read remote questions config: ${message}` },
{ status: 500, headers: { "Cache-Control": "no-store" } },
);
}
}
// ─── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: Request): Promise<Response> {
try {
const body = (await request.json()) as Record<string, unknown>;
const {
channel,
channelId,
timeoutMinutes: rawTimeout,
pollIntervalSeconds: rawPoll,
} = body as {
channel: unknown;
channelId: unknown;
timeoutMinutes: unknown;
pollIntervalSeconds: unknown;
};
// Validate channel
if (!isValidChannel(channel)) {
return Response.json(
{
error: `Invalid channel type: must be one of ${VALID_CHANNELS.join(", ")}`,
},
{ status: 400, headers: { "Cache-Control": "no-store" } },
);
}
// Validate channelId
if (typeof channelId !== "string" || !channelId) {
return Response.json(
{ error: "channelId is required and must be a non-empty string" },
{ status: 400, headers: { "Cache-Control": "no-store" } },
);
}
if (!CHANNEL_ID_PATTERNS[channel].test(channelId)) {
return Response.json(
{
error: `Invalid channel ID format for ${channel}. Expected pattern: ${CHANNEL_ID_PATTERNS[channel].source}`,
},
{ status: 400, headers: { "Cache-Control": "no-store" } },
);
}
// Clamp timeout and poll interval
const timeoutMinutes = clamp(
rawTimeout as number | undefined,
DEFAULT_TIMEOUT_MINUTES,
MIN_TIMEOUT_MINUTES,
MAX_TIMEOUT_MINUTES,
);
const pollIntervalSeconds = clamp(
rawPoll as number | undefined,
DEFAULT_POLL_INTERVAL_SECONDS,
MIN_POLL_INTERVAL_SECONDS,
MAX_POLL_INTERVAL_SECONDS,
);
// Read current preferences
const prefsPath = getPreferencesPath();
let data: Record<string, unknown> = {};
let body2 = "";
if (existsSync(prefsPath)) {
const content = readFileSync(prefsPath, "utf-8");
const parsed = parseFrontmatter(content);
data = parsed.data;
body2 = parsed.body;
}
// Update remote_questions block
data.remote_questions = {
channel,
channel_id: channelId,
timeout_minutes: timeoutMinutes,
poll_interval_seconds: pollIntervalSeconds,
};
// Write back
const dir = dirname(prefsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(prefsPath, writeFrontmatter(data, body2), "utf-8");
return Response.json(
{
success: true,
config: { channel, channelId, timeoutMinutes, pollIntervalSeconds },
},
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Response.json(
{ error: `Failed to save remote questions config: ${message}` },
{ status: 500, headers: { "Cache-Control": "no-store" } },
);
}
}
// ─── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(): Promise<Response> {
try {
const prefsPath = getPreferencesPath();
if (!existsSync(prefsPath)) {
return Response.json(
{ success: true },
{ headers: { "Cache-Control": "no-store" } },
);
}
const content = readFileSync(prefsPath, "utf-8");
const { data, body, hasFrontmatter } = parseFrontmatter(content);
if (!hasFrontmatter || !data.remote_questions) {
return Response.json(
{ success: true },
{ headers: { "Cache-Control": "no-store" } },
);
}
delete data.remote_questions;
writeFileSync(prefsPath, writeFrontmatter(data, body), "utf-8");
return Response.json(
{ success: true },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Response.json(
{ error: `Failed to remove remote questions config: ${message}` },
{ status: 500, headers: { "Cache-Control": "no-store" } },
);
}
}
// ─── PATCH (save bot token) ───────────────────────────────────────────────────
export async function PATCH(request: Request): Promise<Response> {
try {
const body = (await request.json()) as Record<string, unknown>;
const { channel, token } = body as { channel: unknown; token: unknown };
if (!isValidChannel(channel)) {
return Response.json(
{
error: `Invalid channel type: must be one of ${VALID_CHANNELS.join(", ")}`,
},
{ status: 400, headers: { "Cache-Control": "no-store" } },
);
}
if (typeof token !== "string" || !token.trim()) {
return Response.json(
{ error: "token is required and must be a non-empty string" },
{ status: 400, headers: { "Cache-Control": "no-store" } },
);
}
const trimmedToken = token.trim();
const providerId = AUTH_PROVIDER_IDS[channel];
// Read existing auth data, merge the new credential
const authData = readAuthData();
const existingEntry = authData[providerId];
const existingCreds: unknown[] = existingEntry
? Array.isArray(existingEntry)
? existingEntry
: [existingEntry]
: [];
// Replace any existing api_key credential, keep OAuth
const oauthCreds = existingCreds.filter((c: unknown) => {
if (typeof c !== "object" || c === null) return false;
return (c as Record<string, unknown>).type === "oauth";
});
const newCred = { type: "api_key", key: trimmedToken };
const merged = [...oauthCreds, newCred];
authData[providerId] = merged.length === 1 ? merged[0] : merged;
writeAuthData(authData);
// Also set in process.env so it's available immediately
const envVar = ENV_KEYS[channel];
process.env[envVar] = trimmedToken;
return Response.json(
{ success: true, masked: maskToken(trimmedToken) },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Response.json(
{ error: `Failed to save bot token: ${message}` },
{ status: 500, headers: { "Cache-Control": "no-store" } },
);
}
}