check-safety/frontend.go
Alex Dunmow 8418d2535b allowlist CMS registry browse/detail fetch() to orchestrator endpoints
The CMS registry browse tab and new Phase-5 detail view call the
orchestrator's public Connect endpoints (PluginRegistryService /
PluginReviewService) on a different origin. The CMS transport and generated
hooks only cover the CMS's own proto surface, so these are plain JSON POSTs
to the registry URL — same rationale routes/admin/plugins.tsx carried before
the BrowseRegistryTab extraction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:08 +08:00

440 lines
16 KiB
Go

package main
import (
"os"
"path/filepath"
"regexp"
"strings"
)
type frontendViolation struct {
file string
line int
rule string
snippet string
}
// Future enhancement (FUTURE-WO-012-ENFORCE): Add a lint rule that requires
// every string literal in a JSX text node to be wrapped in t(). This was
// intentionally omitted from WO-012 to avoid breaking all in-flight WOs that
// have untranslated strings. The scaffolding (i18next init, en.json catalog,
// t() + useTranslation exports) was shipped in WO-012; strict enforcement is
// deferred to a follow-up work order.
// Files/dirs where fetch() and manual clients are allowed.
// Each entry has a documented reason — add new entries only with justification.
var allowedFrontendFiles = map[string]bool{
"lib/transport.ts": true, // Global transport setup
"lib/api/transport.ts": true, // Orchestrator global transport setup
"lib/token-refresh.ts": true, // Auth bootstrap client (avoids interceptor loop)
"lib/sync/sync-engine.ts": true, // Jotai sync — lives outside React, can't use hooks
"components/media-library/media-upload-area.tsx": true, // Multipart file upload via FormData
"components/html-editor/preview-iframe.tsx": true, // Block HTML preview (returns HTML, not proto)
"lib/editor-bridge/iframe-script.ts": true, // Editor iframe preview
"player/human-proof/index.ts": true, // Standalone player bundle — no React, fetches public REST endpoint
// createClient exceptions — each has a technical reason why useQuery/useMutation can't be used:
// use-entity-search fans one debounced query out to 10 RPCs across 8 services
// via useQueries (single combined loading state + result blending for the
// command palette); generated per-RPC hooks can't express this without 10
// independent hook instances and manual aggregation.
"hooks/use-entity-search.ts": true,
"routes/admin/plugins.tsx": true, // Server-streaming (for await...of) — useMutation doesn't support streaming
"hooks/use-restart-operation.ts": true, // Server-streaming + raw Connect envelope for cross-origin orchestrator stream
"routes/admin/menus.tsx": true, // Imperative async callback in useCallback
"components/data-platform/tables/creation-wizard.tsx": true, // useMutation has serialization bug with oneof fields
// SiteAgentService.SendMessage is a Connect server-streaming RPC consumed
// with `for await` — generated Connect Query hooks are unary-only and
// cannot express a streaming turn (same reason as use-restart-operation).
"components/site-agent/use-site-agent-stream.ts": true,
// Registry browse/detail call the ORCHESTRATOR's public Connect endpoints
// (PluginRegistryService/PluginReviewService) on a different origin. The
// CMS transport and generated hooks only cover the CMS's own proto surface,
// so these are plain JSON POSTs to the registry URL from GetAuthConfig
// (same reason routes/admin/plugins.tsx carried before the extraction).
"components/plugins/browse-registry-tab.tsx": true,
"components/plugins/registry-detail.tsx": true,
}
// Plugin web files that are allowed to call plugin-owned REST handlers because
// there is no ConnectRPC surface for them yet. Keep this list narrow.
var allowedPluginRESTFiles = map[string]bool{
// Suffix-matched (see pluginRESTFileAllowed), so these cover calcomblock both
// as the standalone plugin repo (plugins/calcomblock/web/...) and the legacy
// bundled path (backend/internal/plugins/calcomblock/web/...).
"plugins/calcomblock/web/settings.tsx": true, // Plugin settings/test endpoints are mounted via HTTPHandler; no generated hooks exist yet
"plugins/calcomblock/web/editor.tsx": true, // Block editor reads plugin /settings + /event-types via HTTPHandler routes
}
// Specific fetch paths that are non-proto REST endpoints (no ConnectRPC equivalent)
// These get a WARN, not a FAIL
var knownNonProtoFetches = map[string]bool{
"/api/plugins/": true, // Plugin REST APIs
"/api/mcp/": true, // MCP device auth flow
"/api/lists/subscribe": true, // Public subscribe endpoint
"/api/helpdesk/": true, // Helpdesk attachment multipart upload/download (ConnectRPC doesn't support multipart)
"/preview/": true, // Preview HTML endpoints
"/api/ai/chat/stream": true, // AI chat SSE streaming (EventSource/fetch — ConnectRPC doesn't support SSE)
"/api/support/": true, // CMS helpdesk widget: multipart attachment upload to the Chi proxy — connect-query can't send FormData
"/api/helpdesk/upload": true, // Orchestrator helpdesk: multipart attachment upload — same FormData limitation
"/.well-known/skills/": true, // Public well-known skill discovery index (instance-derived skill name) — no proto service
}
var (
// Anti-pattern: importing axios
reAxios = regexp.MustCompile(`(?:import|require).*['"]axios['"]`)
// Anti-pattern: useQueryClient (should use refetch() instead)
reUseQueryClient = regexp.MustCompile(`useQueryClient\s*\(`)
// Anti-pattern in plugins: QueryClientProvider is already mounted by core
reQueryClientProvider = regexp.MustCompile(`\bQueryClientProvider\b`)
// Anti-pattern: createClient from connectrpc (should use generated hooks)
reCreateClient = regexp.MustCompile(`createClient\s*\(`)
// Anti-pattern: fetch() to /api/ or proto paths (should use ConnectRPC)
reFetchAPI = regexp.MustCompile(`fetch\s*\(\s*['\x60"/]`)
// Anti-pattern: createConnectTransport (only allowed in transport.ts)
reCreateTransport = regexp.MustCompile(`createConnectTransport\s*\(`)
// Anti-pattern: new XMLHttpRequest
reXHR = regexp.MustCompile(`new\s+XMLHttpRequest`)
// Anti-pattern: importing from @connectrpc/connect-web directly to create clients
reConnectWebImport = regexp.MustCompile(`from\s+['"]@connectrpc/connect-web['"]`)
// Anti-pattern: browser confirm() instead of the shared confirm dialog
reWindowConfirm = regexp.MustCompile(`\bwindow\.confirm\s*\(`)
reBareConfirm = regexp.MustCompile(`(?:^|[^\w.])confirm\s*\(`)
// Anti-pattern: raw <button> in JSX instead of the shared <Button> component.
// Raw buttons bypass the action=/entity= automation attributes
// (docs/BUTTON_AUTOMATION.md), so MCP/Puppeteer can't target them.
reRawButton = regexp.MustCompile(`<button\b`)
)
func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings []frontendViolation) {
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".ts" && ext != ".tsx" && ext != ".js" && ext != ".jsx" {
return nil
}
// Skip node_modules and build output.
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
// Skip test/spec files — they legitimately use createClient/transport and
// fetch mocks to exercise the transport layer and are never shipped UI.
// (Matches the convention the coming-soon and TODO checks already follow.)
if strings.HasSuffix(path, ".test.ts") || strings.HasSuffix(path, ".test.tsx") ||
strings.HasSuffix(path, ".spec.ts") || strings.HasSuffix(path, ".spec.tsx") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
if relPath == "" {
relPath = path
}
// Check if this file is in the allow list
isAllowed := false
for allowed := range allowedFrontendFiles {
if strings.HasSuffix(relPath, allowed) || relPath == allowed {
isAllowed = true
break
}
}
// Raw <button> only applies to JSX files; plain .ts/.js build DOM via
// HTML strings for non-React surfaces (player, editor iframe) where the
// <Button> component doesn't exist. components/ui/ defines the
// primitives, so it is exempt (same carve-out as the automation check).
checkRawButton := (ext == ".tsx" || ext == ".jsx") &&
!strings.HasPrefix(filepath.ToSlash(relPath), "components/ui/")
lines, err := readSourceLines(path)
if err != nil {
return nil
}
for idx, line := range lines {
lineNum := idx + 1
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
// Check axios
if reAxios.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-axios",
snippet: strings.TrimSpace(line),
})
}
// Check useQueryClient
if reUseQueryClient.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-useQueryClient",
snippet: strings.TrimSpace(line),
})
}
// Check XMLHttpRequest
if reXHR.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-xhr",
snippet: strings.TrimSpace(line),
})
}
if isBrowserConfirmCall(lines, idx) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-browser-confirm",
snippet: strings.TrimSpace(line),
})
}
if checkRawButton && isRawButtonJSXLine(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-raw-button",
snippet: strings.TrimSpace(line),
})
}
// For allowed files, skip the remaining checks
if isAllowed {
continue
}
// Check createClient (only outside allowed files)
if reCreateClient.MatchString(line) {
// Check if it's a documented exception (streaming, oneof bug, etc.)
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createClient",
snippet: strings.TrimSpace(line),
})
}
// Check createConnectTransport
if reCreateTransport.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createConnectTransport",
snippet: strings.TrimSpace(line),
})
}
// Check @connectrpc/connect-web imports (manual client creation)
if reConnectWebImport.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-connect-web-import",
snippet: strings.TrimSpace(line),
})
}
// Check fetch() calls to /api/ or proto RPC paths
if reFetchAPI.MatchString(line) {
isProtoFetch := strings.Contains(line, "/blockninja.v1.")
isAPIFetch := strings.Contains(line, "/api/")
if isProtoFetch {
// fetch() to a proto RPC endpoint — should always use ConnectRPC client
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-proto",
snippet: strings.TrimSpace(line),
})
} else if isAPIFetch {
// Check if this is a known non-proto endpoint (warn, not fail)
isKnownNonProto := false
for prefix := range knownNonProtoFetches {
if strings.Contains(line, prefix) {
isKnownNonProto = true
break
}
}
if isKnownNonProto {
warnings = append(warnings, frontendViolation{
file: relPath, line: lineNum, rule: "fetch-non-proto-api",
snippet: strings.TrimSpace(line),
})
} else {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-api",
snippet: strings.TrimSpace(line),
})
}
}
}
}
return nil
}); err != nil {
warnings = append(warnings, frontendViolation{
file: filepath.ToSlash(webSrcDir),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations, warnings
}
// checkPluginPages scans plugin web page directories for plugin-specific frontend
// anti-patterns. Plugins must use useQuery/useMutation with { transport } options
// (AGENT-GUIDE §7.2c) — createClient+useMemo is forbidden. Plugins must also
// never use raw fetch() and must not mount their own QueryClientProvider because
// core already provides it.
func checkPluginPages(dirs []string) []frontendViolation {
var violations []frontendViolation
for _, dir := range dirs {
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".ts" && ext != ".tsx" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(dir, path)
if relPath == "" {
relPath = path
}
pluginRESTAllowed := pluginRESTFileAllowed(path)
lines, openErr := readSourceLines(path)
if openErr != nil {
return nil
}
for idx, line := range lines {
lineNum := idx + 1
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
if reFetchAPI.MatchString(line) && !pluginRESTAllowed {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-in-plugin",
snippet: strings.TrimSpace(line),
})
}
if reQueryClientProvider.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-queryclientprovider-in-plugin",
snippet: "remove QueryClientProvider; BlockNinja core provides it.",
})
}
if reCreateClient.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createClient-in-plugin",
snippet: strings.TrimSpace(line),
})
}
if isBrowserConfirmCall(lines, idx) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-browser-confirm-in-plugin",
snippet: strings.TrimSpace(line),
})
}
if ext == ".tsx" && !strings.HasPrefix(filepath.ToSlash(relPath), "components/ui/") &&
isRawButtonJSXLine(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-raw-button-in-plugin",
snippet: strings.TrimSpace(line),
})
}
}
return nil
}); err != nil {
violations = append(violations, frontendViolation{
file: filepath.ToSlash(dir),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
}
return violations
}
func pluginRESTFileAllowed(path string) bool {
slashPath := filepath.ToSlash(path)
for allowed := range allowedPluginRESTFiles {
if strings.HasSuffix(slashPath, allowed) {
return true
}
}
return false
}
func readSourceLines(path string) ([]string, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return strings.Split(string(content), "\n"), nil
}
func isCommentLine(trimmed string) bool {
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*")
}
// isRawButtonJSXLine reports whether the line opens a raw HTML <button>
// element. JSX comment lines ({/* ... */}) are ignored here; the walkers
// already filter //-style comment lines via isCommentLine.
func isRawButtonJSXLine(line string) bool {
if strings.HasPrefix(strings.TrimSpace(line), "{/*") {
return false
}
return reRawButton.MatchString(line)
}
func isBrowserConfirmCall(lines []string, idx int) bool {
line := lines[idx]
if reWindowConfirm.MatchString(line) {
return true
}
if !reBareConfirm.MatchString(line) {
return false
}
start := max(idx-1, 0)
end := min(idx+8, len(lines)-1)
window := strings.Join(lines[start:end+1], " ")
window = strings.Join(strings.Fields(window), " ")
if strings.Contains(window, "await confirm(") {
return false
}
if strings.Contains(window, "confirm(") && strings.Contains(window, ").then(") {
return false
}
return true
}