#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const sourceRoots = [ path.join(repoRoot, "src", "telegram"), path.join(repoRoot, "src", "discord"), path.join(repoRoot, "src", "slack"), path.join(repoRoot, "src", "signal"), path.join(repoRoot, "src", "imessage"), path.join(repoRoot, "src", "web"), path.join(repoRoot, "src", "channels"), path.join(repoRoot, "src", "routing"), path.join(repoRoot, "src", "line"), path.join(repoRoot, "extensions"), ]; // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. const allowedRawFetchCallsites = new Set([ "extensions/bluebubbles/src/types.ts:131", "extensions/feishu/src/streaming-card.ts:31", "extensions/feishu/src/streaming-card.ts:100", "extensions/feishu/src/streaming-card.ts:141", "extensions/feishu/src/streaming-card.ts:197", "extensions/google-gemini-cli-auth/oauth.ts:372", "extensions/google-gemini-cli-auth/oauth.ts:408", "extensions/google-gemini-cli-auth/oauth.ts:447", "extensions/google-gemini-cli-auth/oauth.ts:507", "extensions/google-gemini-cli-auth/oauth.ts:575", "extensions/googlechat/src/api.ts:22", "extensions/googlechat/src/api.ts:43", "extensions/googlechat/src/api.ts:63", "extensions/googlechat/src/api.ts:184", "extensions/googlechat/src/auth.ts:82", "extensions/matrix/src/directory-live.ts:41", "extensions/matrix/src/matrix/client/config.ts:171", "extensions/mattermost/src/mattermost/client.ts:211", "extensions/mattermost/src/mattermost/monitor.ts:230", "extensions/mattermost/src/mattermost/probe.ts:27", "extensions/minimax-portal-auth/oauth.ts:71", "extensions/minimax-portal-auth/oauth.ts:112", "extensions/msteams/src/graph.ts:39", "extensions/nextcloud-talk/src/room-info.ts:92", "extensions/nextcloud-talk/src/send.ts:107", "extensions/nextcloud-talk/src/send.ts:198", "extensions/qwen-portal-auth/oauth.ts:46", "extensions/qwen-portal-auth/oauth.ts:80", "extensions/talk-voice/index.ts:27", "extensions/thread-ownership/index.ts:105", "extensions/voice-call/src/providers/plivo.ts:95", "extensions/voice-call/src/providers/telnyx.ts:61", "extensions/voice-call/src/providers/tts-openai.ts:111", "extensions/voice-call/src/providers/twilio/api.ts:23", "src/channels/telegram/api.ts:8", "src/discord/send.outbound.ts:347", "src/discord/voice-message.ts:267", "src/slack/monitor/media.ts:64", "src/slack/monitor/media.ts:68", "src/slack/monitor/media.ts:82", "src/slack/monitor/media.ts:108", ]); function isTestLikeFile(filePath) { return ( filePath.endsWith(".test.ts") || filePath.endsWith(".test-utils.ts") || filePath.endsWith(".test-harness.ts") || filePath.endsWith(".e2e-harness.ts") || filePath.endsWith(".browser.test.ts") || filePath.endsWith(".node.test.ts") ); } async function collectTypeScriptFiles(targetPath) { const stat = await fs.stat(targetPath); if (stat.isFile()) { if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) { return []; } return [targetPath]; } const entries = await fs.readdir(targetPath, { withFileTypes: true }); const files = []; for (const entry of entries) { const entryPath = path.join(targetPath, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules") { continue; } files.push(...(await collectTypeScriptFiles(entryPath))); continue; } if (!entry.isFile()) { continue; } if (!entryPath.endsWith(".ts")) { continue; } if (isTestLikeFile(entryPath)) { continue; } files.push(entryPath); } return files; } function unwrapExpression(expression) { let current = expression; while (true) { if (ts.isParenthesizedExpression(current)) { current = current.expression; continue; } if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { current = current.expression; continue; } if (ts.isNonNullExpression(current)) { current = current.expression; continue; } return current; } } function isRawFetchCall(expression) { const callee = unwrapExpression(expression); if (ts.isIdentifier(callee)) { return callee.text === "fetch"; } if (ts.isPropertyAccessExpression(callee)) { return ( ts.isIdentifier(callee.expression) && callee.expression.text === "globalThis" && callee.name.text === "fetch" ); } return false; } export function findRawFetchCallLines(content, fileName = "source.ts") { const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); const lines = []; const visit = (node) => { if (ts.isCallExpression(node) && isRawFetchCall(node.expression)) { const line = sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)).line + 1; lines.push(line); } ts.forEachChild(node, visit); }; visit(sourceFile); return lines; } export async function main() { const files = ( await Promise.all( sourceRoots.map(async (sourceRoot) => { try { return await collectTypeScriptFiles(sourceRoot); } catch { return []; } }), ) ).flat(); const violations = []; for (const filePath of files) { const content = await fs.readFile(filePath, "utf8"); const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/"); for (const line of findRawFetchCallLines(content, filePath)) { const callsite = `${relPath}:${line}`; if (allowedRawFetchCallsites.has(callsite)) { continue; } violations.push(callsite); } } if (violations.length === 0) { return; } console.error("Found raw fetch() usage in channel/plugin runtime sources outside allowlist:"); for (const violation of violations.toSorted()) { console.error(`- ${violation}`); } console.error( "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.", ); process.exit(1); } const isDirectExecution = (() => { const entry = process.argv[1]; if (!entry) { return false; } return path.resolve(entry) === fileURLToPath(import.meta.url); })(); if (isDirectExecution) { main().catch((error) => { console.error(error); process.exit(1); }); }