singularity-forge/web/app/api/remote-questions/route.ts
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

404 lines
14 KiB
TypeScript

import { homedir } from "node:os"
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"
import { join, dirname } 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" } },
)
}
}