/** * Tina4 Dev Admin — Built-in development dashboard, zero dependencies. * * Auto-registered admin panel for development mode. * Provides API endpoints and a single-page UI at /__dev/ for: * - Route inspector (all registered routes, methods) * - Message log (tracked debug messages) * - Request inspector (captured HTTP requests) * - System info (Node.js version, V8, memory, uptime, platform) */ import { cpus as osCpus } from "node:os"; import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from "node:fs"; import { join, dirname, resolve, relative } from "node:path"; import { fileURLToPath } from "node:url"; import type { Router } from "./router.js"; import type { RouteHandler, Tina4Request } from "./types.js"; import { DevMailbox } from "./devMailbox.js"; import { isTruthy } from "./dotenv.js"; import { fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js"; import { registerFeedbackRoutes } from "./feedback.js"; import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js"; import { timingSafeEqual } from "node:crypto"; // VERSION-DEC-01 (feature 130): the dashboard reads the SAME resolved version // as server.ts's banner/health and mcp.ts's default dev server -- one shared // walk-up resolver in ./version.ts, not this file's own two-fixed-path reader // (which also floored at "0.0.0" once @tina4/core was relocated out of the // monorepo layout). import { TINA4_VERSION } from "./version.js"; const cpuCount = osCpus().length; // ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ────────── // The dashboard can write files, run SQL and install packages, so it must assume // the developer ALSO browses the web. Two fail-closed gates guard every /__dev // write, and a secret denylist guards the file-read surface. Mirrors the Python // master (tina4_python/dev_admin/__init__.py). /** Safe HTTP methods that never carry a state change — they skip the write gate. */ export const DEV_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); /** * The MCP surface carries its OWN richer loopback+token+remote gate * (mcpRequestAllowed -> 404), so the REST loopback gate skips these prefixes and * lets the MCP gate govern (keeps the mcp/call refusal a 404, not a 403). */ const DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"]; /** Private-key / credential basenames the file endpoints must never serve. */ const DEV_SECRET_BASENAMES = new Set([ ".env", ".envrc", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", ]); const DEV_SECRET_SUFFIXES = [".pem", ".key", ".pfx", ".p12", ".keystore", ".jks"]; /** HTML-escape `& < > " '` — the injected toolbar reflects the raw request path. */ function escapeHtml(value: string): string { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Fail-closed same-origin check for a dev-admin mutation (DEVADMIN-DEC-01). * * A drive-by CSRF is a BROWSER cross-origin request, and a modern browser always * sends `Sec-Fetch-Site` (and any browser sends `Origin` on a cross-origin POST): * - Sec-Fetch-Site present -> trust the browser's classification * (cross-site refused; same-origin / same-site / none ok). * - else Origin present -> require its host to match the request Host. * - else neither header -> not a browser cross-origin request (curl, a test * client, a server-side caller); it cannot be a drive-by, so allow here and * let the loopback gate still constrain the peer. */ export function devSameOriginOk(req: Tina4Request): boolean { const secFetchSite = (req.header("sec-fetch-site") ?? "").trim().toLowerCase(); if (secFetchSite) { return secFetchSite === "same-origin" || secFetchSite === "same-site" || secFetchSite === "none"; } const origin = (req.header("origin") ?? "").trim(); if (origin) { const netloc = origin.includes("://") ? origin.split("://", 2)[1] : origin; const host = (req.header("host") ?? "").trim(); return host !== "" && netloc.toLowerCase() === host.toLowerCase(); } return true; } /** * Return `{status, error}` to REFUSE a dev-admin write, or `null` to allow. * * Two independent fail-closed gates on every /__dev mutation: * DEVADMIN-DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF. * DEVADMIN-DEC-02 loopback peer (all writes EXCEPT the MCP surface, which * carries its own gate) - a network-exposed debug box. * Reads the RAW socket peer (never X-Forwarded-For), exactly like the MCP gate. */ export function devMutationDenial(req: Tina4Request): { status: number; error: string } | null { if (!devSameOriginOk(req)) { return { status: 403, error: "dev-admin: refused (cross-origin request)" }; } const path = req.path ?? ""; const isMcp = DEV_MCP_PREFIXES.some((prefix) => path.startsWith(prefix)); if (!isMcp) { const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? ""; if (!(isLoopback(peer) || mcpTokenOk(req))) { return { status: 403, error: "dev-admin: refused (non-loopback peer)" }; } } return null; } /** * True when `rel` names secret material the file endpoints must never serve * (DEVADMIN-DEC-03): `.env` / `.env.*` (the `.env.example` template is allowed), * anything under `.git/` or `secrets/`, and private-key material. */ export function isSecretPath(rel: string): boolean { const norm = (rel ?? "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase(); if (!norm) return false; const parts = norm.split("/"); if (parts.some((p) => p === ".git" || p === "secrets")) return true; const base = parts[parts.length - 1]; if (base === ".env.example") return false; if (base === ".env" || base.startsWith(".env.")) return true; if (DEV_SECRET_BASENAMES.has(base)) return true; return DEV_SECRET_SUFFIXES.some((suffix) => base.endsWith(suffix)); } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface LogEntry { id: string; timestamp: string; category: string; level: string; message: string; data?: unknown; } interface RequestEntry { id: string; timestamp: string; method: string; path: string; status: number; durationMs: number; } interface RequestStats { total: number; avgMs: number; errors: number; slowestMs: number; } interface ErrorEntry { id: string; timestamp: string; message: string; stack?: string; resolved: boolean; } interface QueueJob { id: string; timestamp: string; name: string; status: "pending" | "completed" | "failed" | "reserved"; payload?: unknown; result?: unknown; error?: string; } interface WsConnection { id: string; connectedAt: string; remoteAddress: string; path: string; } // --------------------------------------------------------------------------- // MessageLog — In-memory message log for dev mode tracking // --------------------------------------------------------------------------- export class MessageLog { private static messages: LogEntry[] = []; private static maxMessages = 500; static log(category: string, level: string, message: string, data?: unknown): void { const entry: LogEntry = { id: `${Date.now()}_${this.messages.length}`, timestamp: new Date().toISOString(), category, level, message, data, }; this.messages.push(entry); if (this.messages.length > this.maxMessages) { this.messages = this.messages.slice(-this.maxMessages); } } static get(category?: string, limit = 100): LogEntry[] { let msgs = this.messages; if (category) { msgs = msgs.filter((m) => m.category === category); } return msgs.slice().reverse().slice(0, limit); } static clear(category?: string): void { if (category) { this.messages = this.messages.filter((m) => m.category !== category); } else { this.messages = []; } } static count(): Record { const counts: Record = { total: this.messages.length }; for (const m of this.messages) { counts[m.category] = (counts[m.category] ?? 0) + 1; } return counts; } } // --------------------------------------------------------------------------- // RequestInspector — Captures recent HTTP requests // --------------------------------------------------------------------------- export class RequestInspector { private static requests: RequestEntry[] = []; private static maxRequests = 200; static capture(method: string, path: string, status: number, duration: number): void { const entry: RequestEntry = { id: `${Date.now()}_${this.requests.length}`, timestamp: new Date().toISOString(), method, path, status, durationMs: Math.round(duration * 100) / 100, }; this.requests.push(entry); if (this.requests.length > this.maxRequests) { this.requests = this.requests.slice(-this.maxRequests); } } static get(limit = 50): RequestEntry[] { return this.requests.slice().reverse().slice(0, limit); } static stats(): RequestStats { if (this.requests.length === 0) { return { total: 0, avgMs: 0, errors: 0, slowestMs: 0 }; } const durations = this.requests.map((r) => r.durationMs); const errors = this.requests.filter((r) => r.status >= 400).length; return { total: this.requests.length, avgMs: Math.round((durations.reduce((a, b) => a + b, 0) / durations.length) * 100) / 100, errors, slowestMs: Math.round(Math.max(...durations) * 100) / 100, }; } static clear(): void { this.requests = []; } } // --------------------------------------------------------------------------- // ErrorTracker — In-memory tracked errors for dev mode // --------------------------------------------------------------------------- export class ErrorTracker { private static errors: ErrorEntry[] = []; private static maxErrors = 200; private static registered = false; /** * Capture an error with dedup (matches PHP/Ruby/Python signature). * Duplicate errors (same message) increment count and update last_seen. */ static capture(errorType: string, message: string, traceback = "", file = "", line = 0): void { const fingerprint = `${errorType}|${message}|${file}|${line}`; const existing = this.errors.find((e) => (e as any).fingerprint === fingerprint); const now = new Date().toISOString(); if (existing) { (existing as any).count = ((existing as any).count || 1) + 1; (existing as any).last_seen = now; existing.resolved = false; // re-open resolved duplicates } else { this.errors.push({ id: `err_${Date.now()}_${this.errors.length}`, timestamp: now, message, stack: traceback || undefined, resolved: false, ...({ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now } as any), }); if (this.errors.length > this.maxErrors) { this.errors = this.errors.slice(-this.maxErrors); } } } /** Legacy alias for capture (backward compatibility). */ static track(message: string, stack?: string): void { this.capture("Error", message, stack || ""); } static get(): ErrorEntry[] { return this.errors.slice().reverse(); } static resolve(id: string): boolean { const entry = this.errors.find((e) => e.id === id); if (entry) { entry.resolved = true; return true; } return false; } static clearResolved(): void { this.errors = this.errors.filter((e) => !e.resolved); } /** Remove ALL tracked errors. */ static clearAll(): void { this.errors = []; } /** Health summary — are there unresolved errors? */ static health(): { healthy: boolean; total: number; unresolved: number; resolved: number } { const total = this.errors.length; const resolved = this.errors.filter((e) => e.resolved).length; const unresolved = total - resolved; return { healthy: unresolved === 0, total, unresolved, resolved }; } /** Count of unresolved errors. */ static unresolvedCount(): number { return this.errors.filter((e) => !e.resolved).length; } /** Reset all state (for testing). */ static reset(): void { this.errors = []; this.registered = false; } /** * Register global error handlers to feed the tracker. * Safe to call multiple times — only registers once. */ static register(): void { if (this.registered) return; this.registered = true; process.on("uncaughtException", (err: Error) => { this.capture( err.constructor.name, err.message, err.stack || "", ); }); process.on("unhandledRejection", (reason: unknown) => { const err = reason instanceof Error ? reason : new Error(String(reason)); this.capture( err.constructor.name, err.message, err.stack || "", ); }); } } // --------------------------------------------------------------------------- // DevMailboxStore — File-backed dev mailbox (delegates to DevMailbox) // --------------------------------------------------------------------------- export class DevMailboxStore { private static mailbox = new DevMailbox(); static inbox(folder: string = "inbox", limit: number = 50, offset: number = 0) { return this.mailbox.inbox(limit, offset, folder); } static read(id: string) { return this.mailbox.read(id); } static seed(count = 5): void { this.mailbox.seed(count); } static clear(folder?: string): void { this.mailbox.clear(folder); } static unreadCount(): number { return this.mailbox.unreadCount(); } static count(folder?: string): { inbox: number; outbox: number; total: number } { return this.mailbox.count(folder); } } // --------------------------------------------------------------------------- // DevQueue — In-memory dev queue // --------------------------------------------------------------------------- export class DevQueue { private static jobs: QueueJob[] = []; static stats(): { pending: number; completed: number; failed: number; reserved: number; jobs: QueueJob[] } { const pending = this.jobs.filter((j) => j.status === "pending").length; const completed = this.jobs.filter((j) => j.status === "completed").length; const failed = this.jobs.filter((j) => j.status === "failed").length; const reserved = this.jobs.filter((j) => j.status === "reserved").length; return { pending, completed, failed, reserved, jobs: this.jobs.slice().reverse() }; } static add(name: string, payload?: unknown): QueueJob { const job: QueueJob = { id: `job_${Date.now()}_${this.jobs.length}`, timestamp: new Date().toISOString(), name, status: "pending", payload, }; this.jobs.push(job); return job; } static retryFailed(): number { let count = 0; for (const job of this.jobs) { if (job.status === "failed") { job.status = "pending"; job.error = undefined; count++; } } return count; } static purgeCompleted(): number { const before = this.jobs.length; this.jobs = this.jobs.filter((j) => j.status !== "completed"); return before - this.jobs.length; } static replay(id: string): QueueJob | undefined { const job = this.jobs.find((j) => j.id === id); if (job) { const newJob = this.add(job.name, job.payload); return newJob; } return undefined; } } // --------------------------------------------------------------------------- // WsTracker — In-memory WebSocket connection tracker // --------------------------------------------------------------------------- export class WsTracker { private static connections: WsConnection[] = []; static add(remoteAddress: string, path: string): string { const conn: WsConnection = { id: `ws_${Date.now()}_${this.connections.length}`, connectedAt: new Date().toISOString(), remoteAddress, path, }; this.connections.push(conn); return conn.id; } static remove(id: string): boolean { const idx = this.connections.findIndex((c) => c.id === id); if (idx >= 0) { this.connections.splice(idx, 1); return true; } return false; } static list(): WsConnection[] { return this.connections.slice(); } } // --------------------------------------------------------------------------- // DevAdmin — Registers /__dev routes on the router // --------------------------------------------------------------------------- export class DevAdmin { /** * Check whether dev mode is enabled. */ static isEnabled(): boolean { return isTruthy(process.env.TINA4_DEBUG); } /** * Register all /__dev routes on the given router. */ static register(router: Router): void { // Register error handlers to feed the ErrorTracker ErrorTracker.register(); // Customer feedback widget routes — gated at request time by // TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST. The handlers // themselves are always registered (so toggling env vars doesn't // require a server restart) but each request re-checks the gate. registerFeedbackRoutes(router); const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [ // Dashboard { method: "GET", pattern: "/__dev", handler: handleDashboard }, { method: "GET", pattern: "/__dev/", handler: handleDashboard }, // Reload — called by Rust CLI on file changes { method: "GET", pattern: "/__dev/api/mtime", handler: handleMtime }, { method: "POST", pattern: "/__dev/api/reload", handler: handleReload }, // Status & system { method: "GET", pattern: "/__dev/api/status", handler: handleStatus(router) }, { method: "GET", pattern: "/__dev/api/system", handler: handleSystem }, // Routes { method: "GET", pattern: "/__dev/api/routes", handler: handleRoutes(router) }, // Messages { method: "GET", pattern: "/__dev/api/messages", handler: handleMessages }, { method: "POST", pattern: "/__dev/api/messages/clear", handler: handleMessagesClear }, { method: "GET", pattern: "/__dev/api/messages/search", handler: handleMessagesSearch }, // Requests { method: "GET", pattern: "/__dev/api/requests", handler: handleRequests }, { method: "POST", pattern: "/__dev/api/requests/clear", handler: handleRequestsClear }, // Queue management { method: "GET", pattern: "/__dev/api/queue", handler: handleQueue }, { method: "GET", pattern: "/__dev/api/queue/topics", handler: handleQueueTopics }, { method: "GET", pattern: "/__dev/api/queue/dead-letters", handler: handleQueueDeadLetters }, { method: "POST", pattern: "/__dev/api/queue/retry", handler: handleQueueRetry }, { method: "POST", pattern: "/__dev/api/queue/purge", handler: handleQueuePurge }, { method: "POST", pattern: "/__dev/api/queue/replay", handler: handleQueueReplay }, // Mailbox { method: "GET", pattern: "/__dev/api/mailbox", handler: handleMailbox }, { method: "GET", pattern: "/__dev/api/mailbox/read", handler: handleMailboxRead }, { method: "POST", pattern: "/__dev/api/mailbox/seed", handler: handleMailboxSeed }, { method: "POST", pattern: "/__dev/api/mailbox/clear", handler: handleMailboxClear }, // Database { method: "GET", pattern: "/__dev/api/table", handler: handleTable }, { method: "GET", pattern: "/__dev/api/tables", handler: handleTables }, { method: "POST", pattern: "/__dev/api/seed", handler: handleSeed }, { method: "POST", pattern: "/__dev/api/query", handler: handleQuery }, // Errors / Broken { method: "GET", pattern: "/__dev/api/broken", handler: handleBroken }, { method: "POST", pattern: "/__dev/api/broken/resolve", handler: handleBrokenResolve }, { method: "POST", pattern: "/__dev/api/broken/clear", handler: handleBrokenClear }, // WebSockets { method: "GET", pattern: "/__dev/api/websockets", handler: handleWebsockets }, { method: "POST", pattern: "/__dev/api/websockets/disconnect", handler: handleWebsocketsDisconnect }, // Tools { method: "POST", pattern: "/__dev/api/tool", handler: handleTool }, // Threads — proxies to Rust agent /threads. Mirrors Python's // _api_threads + _api_threads_sub. { method: "GET", pattern: "/__dev/api/threads", handler: handleThreads }, { method: "POST", pattern: "/__dev/api/threads", handler: handleThreads }, { method: "GET", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub }, { method: "PATCH", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub }, { method: "DELETE", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub }, { method: "GET", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub }, { method: "POST", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub }, // Connections { method: "GET", pattern: "/__dev/api/connections", handler: handleConnections }, { method: "POST", pattern: "/__dev/api/connections/test", handler: handleConnectionsTest }, { method: "POST", pattern: "/__dev/api/connections/save", handler: handleConnectionsSave }, // Gallery { method: "GET", pattern: "/__dev/api/gallery", handler: handleGalleryList }, { method: "POST", pattern: "/__dev/api/gallery/deploy", handler: handleGalleryDeploy(router) }, // Metrics // No fallback (ADR-0002): a missing or stale CLI is a 503 naming the // install command, never zeros that read as a healthy codebase. { method: "GET", pattern: "/__dev/api/metrics/full", handler: (_req: any, res: any) => { try { res.json(fullAnalysis()); } catch (e) { if (e instanceof MetricsEngineError) { res.status(503).json({ error: e.message }); return; } throw e; } } }, { method: "GET", pattern: "/__dev/api/metrics/file", handler: (req: any, res: any) => { const url = new URL(req.url ?? "/", "http://localhost"); const p = (url.searchParams.get("path") || "").toString(); try { res.json(fileDetail(p)); } catch (e) { if (e instanceof MetricsEngineError) { // A bad path is the caller's mistake (404); anything else is the // engine being unavailable (503). const badPath = /no such file|not a file|needs a path/.test(e.message); res.status(badPath ? 404 : 503).json({ error: e.message }); return; } throw e; } } }, // GraphQL schema introspection (auto-discovers registered ORM models) { method: "GET", pattern: "/__dev/api/graphql/schema", handler: async (_req: any, res: any) => { try { const { GraphQL } = await import("./graphql.js"); const gql = new GraphQL(); // Auto-discover ORM models from BaseModel registry try { const orm = await import("../../orm/src/index.js"); const registry = (orm as any).BaseModel?._modelRegistry as Record | undefined; if (registry) { for (const modelClass of Object.values(registry)) { if (modelClass?.tableName && modelClass?.fields) { gql.fromOrm(modelClass); } } } } catch (_ormErr: any) { // ORM package not available — continue with empty schema } res.json({ schema: gql.introspect(), sdl: gql.schemaSdl() }); } catch (e: any) { res.json({ error: e.message }, 400); } }}, // Version check (proxy to avoid CORS) { method: "GET", pattern: "/__dev/api/version-check", handler: handleVersionCheck }, // ── Parity surface area (ported from Python tina4_python.dev_admin) ── // Thoughts / activity feed (live tail of MessageLog for the AI chat pane) { method: "GET", pattern: "/__dev/api/thoughts", handler: handleThoughts }, // Supervise — currently stubbed; full implementation requires a Rust // agent + worktree manager that doesn't exist in tina4-nodejs yet. { method: "POST", pattern: "/__dev/api/supervise/create", handler: handleSuperviseStub }, { method: "GET", pattern: "/__dev/api/supervise/sessions", handler: handleSuperviseStub }, { method: "GET", pattern: "/__dev/api/supervise/diff", handler: handleSuperviseStub }, { method: "POST", pattern: "/__dev/api/supervise/commit", handler: handleSuperviseStub }, { method: "POST", pattern: "/__dev/api/supervise/cancel", handler: handleSuperviseStub }, // Framework-grounding MCP token config — self-contained (.env upsert) { method: "GET", pattern: "/__dev/api/grounding/status", handler: handleGroundingStatus }, { method: "POST", pattern: "/__dev/api/grounding/token", handler: handleGroundingToken }, // Scaffold run chips — project-level operations (distinct from create) { method: "POST", pattern: "/__dev/api/migrate", handler: handleMigrate }, { method: "POST", pattern: "/__dev/api/test", handler: handleTest }, { method: "POST", pattern: "/__dev/api/seed/run", handler: handleSeedRun }, // File browser / editor { method: "GET", pattern: "/__dev/api/files", handler: handleFiles }, { method: "GET", pattern: "/__dev/api/file", handler: handleFileRead }, { method: "POST", pattern: "/__dev/api/file/save", handler: handleFileSave }, { method: "GET", pattern: "/__dev/api/file/raw", handler: handleFileRaw }, { method: "POST", pattern: "/__dev/api/file/rename", handler: handleFileRename }, { method: "POST", pattern: "/__dev/api/file/delete", handler: handleFileDelete }, // Dependency search (npm registry) + install { method: "GET", pattern: "/__dev/api/deps/search", handler: handleDepsSearch }, { method: "POST", pattern: "/__dev/api/deps/install", handler: handleDepsInstall }, // Git status { method: "GET", pattern: "/__dev/api/git/status", handler: handleGitStatus }, // Scaffolding { method: "GET", pattern: "/__dev/api/scaffold", handler: handleScaffoldList }, { method: "POST", pattern: "/__dev/api/scaffold/run", handler: handleScaffoldRun }, // Plan API (ported from Python) { method: "GET", pattern: "/__dev/api/plan/current", handler: handlePlanCurrent }, { method: "GET", pattern: "/__dev/api/plan/list", handler: handlePlanList }, { method: "POST", pattern: "/__dev/api/plan/create", handler: handlePlanCreate }, { method: "POST", pattern: "/__dev/api/plan/switch", handler: handlePlanSwitch }, { method: "POST", pattern: "/__dev/api/plan/complete-step", handler: handlePlanCompleteStep }, { method: "POST", pattern: "/__dev/api/plan/add-step", handler: handlePlanAddStep }, { method: "POST", pattern: "/__dev/api/plan/note", handler: handlePlanNote }, { method: "POST", pattern: "/__dev/api/plan/archive", handler: handlePlanArchive }, { method: "GET", pattern: "/__dev/api/plan/read", handler: handlePlanRead }, { method: "POST", pattern: "/__dev/api/plan/flesh", handler: handlePlanFlesh }, // Project index API { method: "POST", pattern: "/__dev/api/index/rebuild", handler: handleIndexRebuild }, { method: "GET", pattern: "/__dev/api/index/search", handler: handleIndexSearch }, { method: "GET", pattern: "/__dev/api/index/file", handler: handleIndexFile }, { method: "GET", pattern: "/__dev/api/index/overview", handler: handleIndexOverview }, // Live API RAG (Docs) — see plan/v3/22-LIVE-API-RAG.md { method: "GET", pattern: "/__dev/api/docs/search", handler: handleDocsSearch }, { method: "GET", pattern: "/__dev/api/docs/class", handler: handleDocsClass }, { method: "GET", pattern: "/__dev/api/docs/method", handler: handleDocsMethod }, { method: "GET", pattern: "/__dev/api/docs/index", handler: handleDocsIndex }, { method: "GET", pattern: "/__dev/api/docs/.well-known.json", handler: handleDocsWellKnown }, // JS asset { method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs }, // Dev-toolbar assets — served as external CSS/JS so the injected toolbar is // CSP-clean (no inline style=, onclick=, or '; res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.raw.end(spa); }; // Reload mtime counter — updated by POST /__dev/api/reload from Rust CLI let _reloadMtime = 0; let _reloadFile = ""; const handleMtime: RouteHandler = async (_req, res) => { res.json({ mtime: _reloadMtime, file: _reloadFile }); }; const handleReload: RouteHandler = async (req, res) => { _reloadMtime = Math.floor(Date.now() / 1000); const body = req.body as Record | undefined; _reloadFile = (body?.file as string) || ""; const reloadType = (body?.type as string) || "reload"; console.log(` External reload trigger: ${reloadType}${_reloadFile ? ` (${_reloadFile})` : ""}`); // Re-discover so new files in src/routes/ register without a server restart. // rediscoverRoutes() is idempotent — already-loaded files are skipped, only // the new ones run. Add the freshly-discovered routes to the default router. try { const { rediscoverRoutes } = await import("./routeDiscovery.js"); const newRoutes = await rediscoverRoutes(); if (newRoutes.length > 0) { // Add to the LIVE server router — startServer() builds a fresh Router and // exposes it as globalThis.__tina4_router; that's the instance dispatch // matches against. Adding to defaultRouter would land the re-imported // handler in a table nobody serves from, so the stale route keeps winning // (an edited route would never hot-reload). addRoute() replaces by pattern, // so the fresh handler overwrites the old one in place. Fall back to // defaultRouter when no server is running (e.g. unit tests). const liveRouter = (globalThis as any).__tina4_router; const target = liveRouter ?? (await import("./router.js")).defaultRouter; for (const route of newRoutes) target.addRoute(route); console.log(` Re-discovered ${newRoutes.length} route(s) on reload`); } } catch (err) { console.error(` Re-discover on reload failed:`, err); } // Keep the code Context index LIVE on the same reload trigger: reindex just // the changed file (UPSERT) so the dev-MCP code_search reflects the edit // immediately. existingContext() only touches an already-built index (it never // creates one — nothing to keep fresh until code_search has run), and the // whole block is guarded so a context failure never breaks the reload. try { if (_reloadFile) { const { existingContext } = await import("./context/index.js"); const ctx = existingContext(); if (ctx) ctx.reindexFile(_reloadFile); } } catch (err) { console.error(` Context reindex on reload failed:`, err); } // WebSocket-primary reload: push an instant message to every browser // connected on /__dev_reload. The toolbar client (and the dev-admin // dashboard) act on this immediately — the mtime poll is only a fallback for // when the socket is down. CSS changes swap stylesheets; everything else // triggers a full page reload, so we normalise the wire `type` to // "css"/"reload". The HTTP response still echoes the caller's original type. // Wrapped so a broadcast failure (or zero clients) never 500s the endpoint. const wsType = reloadType === "css" ? "css" : "reload"; try { const { devReloadWs } = await import("./websocket.js"); devReloadWs.broadcast(JSON.stringify({ type: wsType, file: _reloadFile, mtime: _reloadMtime })); } catch (err) { console.error(` Dev-reload WebSocket broadcast failed:`, err); } res.json({ ok: true, type: reloadType }); }; function handleStatus(router: Router): RouteHandler { return async (_req, res) => { const mem = process.memoryUsage(); const reqStats = RequestInspector.stats(); const msgCounts = MessageLog.count(); const errors = ErrorTracker.get(); const unresolved = errors.filter((e) => !e.resolved).length; const mailboxCounts = DevMailboxStore.count(); let dbTableCount = 0; try { const { getAdapter } = await import("../../orm/src/index.js"); const db = getAdapter(); dbTableCount = db.getTables().length; } catch { /* no database connected */ } res.json({ nodeVersion: process.version, framework: `tina4-nodejs v${TINA4_VERSION}`, debug: process.env.TINA4_DEBUG ?? "false", logLevel: process.env.TINA4_LOG_LEVEL ?? "ERROR", routes: router.getRoutes().length, db_tables: dbTableCount, messages: msgCounts, message_counts: msgCounts, requests: reqStats, request_stats: { total: reqStats.total, avg_ms: reqStats.avgMs, errors: reqStats.errors, slowest_ms: reqStats.slowestMs }, health: { unresolved }, mailbox: { total: mailboxCounts.total, unread: DevMailboxStore.unreadCount() }, memory: { rss: Math.round(mem.rss / 1048576), heapUsed: Math.round(mem.heapUsed / 1048576), heapTotal: Math.round(mem.heapTotal / 1048576), }, uptime: Math.round(process.uptime()), timestamp: new Date().toISOString(), }); }; } function handleRoutes(router: Router): RouteHandler { const internalPrefixes = ["/__dev", "/health", "/swagger"]; return (_req, res) => { const allRoutes = router.getRoutes(); const result = allRoutes .filter((r) => !internalPrefixes.some((prefix) => r.pattern.startsWith(prefix))) .map((r) => { const filePath = r.filePath ?? null; return { method: r.method, path: r.pattern, pattern: r.pattern, auth_required: (r.secure ?? false) && !(r.noAuth ?? false), handler: filePath ? `${filePath.split("/").pop()}` : "Closure", module: filePath ? filePath.substring(0, filePath.lastIndexOf("/")) : "", filePath, hasMiddleware: (r.middlewares?.length ?? 0) > 0, meta: r.meta ?? null, }; }); res.json({ routes: result, count: result.length }); }; } const handleMessages: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const category = url.searchParams.get("category") ?? undefined; const limit = parseInt(url.searchParams.get("limit") ?? "100", 10); res.json({ messages: MessageLog.get(category, limit), counts: MessageLog.count(), }); }; const handleMessagesClear: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const category = url.searchParams.get("category") ?? undefined; MessageLog.clear(category); res.json({ cleared: true }); }; const handleRequests: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); const rawRequests = RequestInspector.get(limit); const rawStats = RequestInspector.stats(); // Map to shared JS format: duration_ms, body_size, avg_ms, errors, slowest_ms const mappedRequests = rawRequests.map((r) => ({ timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.durationMs, body_size: 0, })); res.json({ requests: mappedRequests, stats: { total: rawStats.total, avg_ms: rawStats.avgMs, errors: rawStats.errors, slowest_ms: rawStats.slowestMs, }, }); }; const handleRequestsClear: RouteHandler = (_req, res) => { RequestInspector.clear(); res.json({ cleared: true }); }; const handleSystem: RouteHandler = async (_req, res) => { const mem = process.memoryUsage(); const heapUsedMb = Math.round(mem.heapUsed / 1048576); const rssMb = Math.round(mem.rss / 1048576); let dbTableCount: number | undefined; let dbConnected = false; try { const { getAdapter } = await import("../../orm/src/index.js"); const db = getAdapter(); dbTableCount = db.getTables().length; dbConnected = true; } catch { /* no database connected */ } // Respond in both the shared-JS format and the Node-specific format res.json({ // Shared JS fields node_version: process.version, platform: process.platform, architecture: process.arch, os: `${process.platform} ${process.arch}`, pid: process.pid, memory_mb: heapUsedMb, db_tables: dbTableCount !== undefined ? dbTableCount : "N/A", db_connected: dbConnected, memory: { current_mb: heapUsedMb, peak_mb: rssMb, limit: "V8 default", rss: `${rssMb} MB`, heapUsed: `${heapUsedMb} MB`, heapTotal: `${Math.round(mem.heapTotal / 1048576)} MB`, external: `${Math.round(mem.external / 1048576)} MB`, }, framework: { name: "tina4-nodejs", version: TINA4_VERSION, route_count: "", }, debug: process.env.TINA4_DEBUG ?? "false", log_level: process.env.TINA4_LOG_LEVEL ?? "ERROR", // Node-specific extras node: { version: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, pid: process.pid, }, uptime: { seconds: Math.round(process.uptime()), formatted: formatUptime(process.uptime()), }, env: { TINA4_DEBUG: process.env.TINA4_DEBUG ?? "false", TINA4_LOG_LEVEL: process.env.TINA4_LOG_LEVEL ?? "ERROR", }, cpus: cpuCount, }); }; // -- Messages search -- const handleMessagesSearch: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const q = (url.searchParams.get("q") ?? "").toLowerCase(); if (!q) { res.json({ messages: [], query: "" }); return; } const all = MessageLog.get(undefined, 500); const results = all.filter( (m) => m.message.toLowerCase().includes(q) || m.category.toLowerCase().includes(q), ); res.json({ messages: results, query: q, count: results.length }); }; // -- Queue handlers -- /** * Map a file-backed QueueJob (LiteBackend on-disk shape) to the shared JS * dev-admin format the SPA queue panel renders. */ function mapQueueJob(job: any, topic: string, status: string) { return { id: job?.id ?? "", topic: job?.topic ?? topic, status, attempts: Number(job?.attempts ?? 0) || 0, created_at: job?.createdAt ?? job?.created_at ?? "", data: job?.payload ?? {}, }; } /** * Read every `*.queue-data` record in one queue directory, oldest name first. * Corrupt files are skipped, exactly as LiteBackend.size() skips them, so the * list and the count always see the same set. */ function readQueueDir(dir: string, topic: string, status: string) { if (!existsSync(dir)) return []; const jobs: Array> = []; for (const filename of readdirSync(dir).sort()) { if (!filename.endsWith(".queue-data")) continue; // skips failed/ + reserved/ subdirs try { jobs.push(mapQueueJob(JSON.parse(readFileSync(join(dir, filename), "utf-8")), topic, status)); } catch { // skip corrupt files } } return jobs; } const handleQueue: RouteHandler = async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const topic = url.searchParams.get("topic") ?? "default"; const statusFilter = url.searchParams.get("status") ?? ""; try { const { Queue, queueBasePath } = await import("./queue.js"); const queue = new Queue({ topic }); const stats = { pending: queue.size("pending"), completed: queue.size("completed"), failed: queue.size("failed"), reserved: queue.size("reserved"), }; // The job list and the stats above MUST describe the same set of jobs. // Two defects broke that (both measured 2026-08-05, see the regression test // test/devAdminQueuePath.test.ts): // // 1. The directory. This scanned a hardcoded cwd/data/queue/ while // Queue.size() reads queueBasePath() — so with TINA4_QUEUE_PATH set the // panel listed one directory and counted another (measured: 100 stale // jobs listed, 12 real jobs counted). // 2. The set. Reserved jobs were counted by stats.reserved but never // listed, and a failed-but-retryable job — which lives in the PENDING // directory with status "pending" — was listed twice: once by the // directory scan and again by queue.failed(), which re-reads the same // files. Each job now appears exactly once, in the bucket its own stat // counts it in: pending -> the queue dir, reserved -> reserved/, // failed/dead -> failed/ (the directory size("failed") counts, read the // same way — queue.deadLetters() applies THIS queue's maxRetries, which // the dev admin cannot know, so it can return fewer jobs than the panel // is showing a count for). const topicDir = join(queueBasePath(), topic); const jobs: Array> = []; if (!statusFilter || statusFilter === "pending") { jobs.push(...readQueueDir(topicDir, topic, "pending")); } if (!statusFilter || statusFilter === "reserved") { jobs.push(...readQueueDir(join(topicDir, "reserved"), topic, "reserved")); } if (!statusFilter || statusFilter === "failed" || statusFilter === "dead") { jobs.push(...readQueueDir(join(topicDir, "failed"), topic, "dead_letter")); } res.json({ stats, jobs }); } catch (e: any) { res.json({ stats: { pending: 0, completed: 0, failed: 0, reserved: 0 }, jobs: [], error: String(e?.message ?? e), }); } }; const handleQueueTopics: RouteHandler = async (_req, res) => { try { // On-disk file-queue topics under the REAL store (TINA4_QUEUE_PATH, else // data/queue); fall back to "default". // This module is ESM ("type": "module"), so a bare require() is a ReferenceError // that the catch below swallowed - the endpoint always returned ["default"] and // never listed a real topic. node:fs/node:path are already imported at the top of // this file, so the "avoids a hard dep" rationale for the require() never held. const { queueBasePath } = await import("./queue.js"); const queueDir = queueBasePath(); let topics: string[] = []; if (existsSync(queueDir)) { topics = readdirSync(queueDir) .filter((d: string) => { try { return statSync(join(queueDir, d)).isDirectory(); } catch { return false; } }) .sort(); } if (topics.length === 0) topics = ["default"]; res.json({ topics }); } catch (e: any) { res.json({ topics: ["default"], error: String(e?.message ?? e) }); } }; const handleQueueDeadLetters: RouteHandler = async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const topic = url.searchParams.get("topic") ?? "default"; // Parity with Python's _api_queue_dead_letters: read the file-backed // dead-letter store (data/queue//failed/*.queue-data) so real // exhausted-retry jobs surface, not just the empty in-memory DevQueue. try { const { Queue } = await import("./queue.js"); const queue = new Queue({ topic }); const jobs = queue.deadLetters().map((j) => mapQueueJob(j, topic, "dead_letter")); res.json({ jobs, count: jobs.length, topic }); } catch (e: any) { res.json({ jobs: [], count: 0, topic, error: String(e?.message ?? e) }); } }; const handleQueueRetry: RouteHandler = (_req, res) => { const count = DevQueue.retryFailed(); res.json({ retried: count }); }; const handleQueuePurge: RouteHandler = (_req, res) => { const count = DevQueue.purgeCompleted(); res.json({ purged: count }); }; const handleQueueReplay: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const id = url.searchParams.get("id") ?? ""; if (!id) { // Try reading from body const bodyId = (req as any).body?.id ?? ""; if (!bodyId) { res.json({ error: "Missing job id" }); return; } const job = DevQueue.replay(bodyId); res.json(job ? { replayed: true, job } : { error: "Job not found" }); return; } const job = DevQueue.replay(id); res.json(job ? { replayed: true, job } : { error: "Job not found" }); }; // -- Mailbox handlers -- const handleMailbox: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const folder = url.searchParams.get("folder") ?? "inbox"; const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); const offset = parseInt(url.searchParams.get("offset") ?? "0", 10); const messages = DevMailboxStore.inbox(folder, limit, offset); const counts = DevMailboxStore.count(); const unread = DevMailboxStore.unreadCount(); res.json({ messages, counts, unread }); }; const handleMailboxRead: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const id = url.searchParams.get("id") ?? ""; const msg = DevMailboxStore.read(id); // Shared JS expects the message fields at top level (not wrapped in .message) res.json(msg ? msg : { error: "Message not found" }); }; const handleMailboxSeed: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const count = parseInt(url.searchParams.get("count") ?? "5", 10); DevMailboxStore.seed(count); res.json({ seeded: count }); }; const handleMailboxClear: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const folder = url.searchParams.get("folder") ?? undefined; DevMailboxStore.clear(folder); res.json({ cleared: true, folder: folder ?? "all" }); }; // -- Database handlers -- /** * Quote a (optionally schema-qualified) table reference for safe interpolation. * Returns null for anything that isn't one or two plain SQL identifiers, so a * hostile ?name= can never reach the SELECT. Mirrors the identifier guard the * sqlite adapter uses for PRAGMA table_info. */ function quoteTableRef(name: string): string | null { const parts = name.split("."); if (parts.length === 0 || parts.length > 2) return null; for (const p of parts) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(p)) return null; } return parts.map((p) => `"${p}"`).join("."); } const handleTable: RouteHandler = async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const name = url.searchParams.get("name") ?? ""; if (!name) { res.json({ error: "Missing table name parameter" }); return; } // Real implementation — parity with Python's _api_table_info: return the // table's column list + a sample of up to 20 rows via the same DB adapter // `tables`/`query` use. try { const { getAdapter } = await import("../../orm/src/index.js"); const db = getAdapter(); const columns = db.getColumns(name); if (!columns.length) { res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" }); return; } const ref = quoteTableRef(name); if (!ref) { // Columns resolved but the name isn't a plain identifier we'll splice // into SQL — surface the schema without the sample rather than risk it. res.json({ table: name, columns, rows: [], count: 0, message: "Invalid table name for sampling" }); return; } const rows = db.fetch(`SELECT * FROM ${ref} LIMIT 20`); res.json({ table: name, columns, rows, count: rows.length }); } catch (e) { res.json({ table: name, columns: [], rows: [], message: (e as Error)?.message ?? "Database not connected or table not found" }); } }; const handleTables: RouteHandler = async (_req, res) => { try { const { getAdapter } = await import("../../orm/src/index.js"); const db = getAdapter(); const tables = db.getTables(); res.json({ tables }); } catch { res.json({ tables: [], message: "Database not connected" }); } }; const handleSeed: RouteHandler = async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const body = (req as any).body ?? {}; const table = url.searchParams.get("table") ?? body?.table ?? ""; const count = parseInt(String(url.searchParams.get("count") ?? body?.count ?? "10"), 10) || 10; // P4b — accept seed/clear/strict; drop the previous hard-coded behaviour. const seedRaw = url.searchParams.get("seed") ?? body?.seed; const seed = seedRaw !== undefined && seedRaw !== null && String(seedRaw) !== "" ? (Number.isNaN(parseInt(String(seedRaw), 10)) ? undefined : parseInt(String(seedRaw), 10)) : undefined; const clear = String(url.searchParams.get("clear") ?? body?.clear ?? "") === "true" || body?.clear === true; const strict = String(url.searchParams.get("strict") ?? body?.strict ?? "") === "true" || body?.strict === true; if (!table) { res.json({ error: "Missing table parameter" }); return; } try { const orm = await import("../../orm/src/index.js"); const db = orm.getAdapter(); const { seedTable } = orm; // A shared FakeData seeds the RNG so a `seed` makes the run reproducible. const fake = new orm.FakeData(seed); const columns = db.getColumns(table); if (!columns.length) { res.json({ error: `Table '${table}' not found or has no columns` }); return; } // Build a field map based on column info (skip auto-increment/id PKs). const fieldMap: Record unknown> = {}; for (const col of columns) { const name = col.name.toLowerCase(); const type = col.type.toLowerCase(); if (name === "id" || (col as any).primaryKey === true) continue; // skip primary key if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); } else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); } else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); } else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); } else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); } else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); } else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.numeric(0, 1000, 2); } else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); } else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); } else { fieldMap[col.name] = () => fake.sentence(3); } } // P1 — delegate to the shared seedTable so each row is wrapped (no // unhandled failure can crash the endpoint) and we get a summary back. // SEED-TABLE-SEED-INERT: seedTable no longer takes opts.seed (it throws // if supplied) — reproducibility already comes from the seeded `fake` // built above and closed over by every entry in fieldMap. const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, strict }); res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table }); } catch (e) { res.json({ error: (e as Error)?.message ?? "Database not connected" }); } }; const handleQuery: RouteHandler = async (req, res) => { const query = ((req as any).body?.query ?? "").trim(); const queryType = (req as any).body?.type ?? "sql"; if (!query) { res.json({ error: "Missing query parameter" }); return; } if (queryType === "graphql") { // GraphQL stub — can be extended when GraphQL module is available res.json({ error: "GraphQL not yet supported in dev admin" }); return; } try { const { getAdapter } = await import("../../orm/src/index.js"); const db = getAdapter(); // Split multiple statements on semicolons const statements = query.split(";").map((s: string) => s.trim()).filter((s: string) => s.length > 0); if (statements.length === 1) { const upper = statements[0].toUpperCase().trimStart(); const isRead = upper.startsWith("SELECT") || upper.startsWith("PRAGMA") || upper.startsWith("SHOW") || upper.startsWith("DESCRIBE"); if (isRead) { const rows = db.fetch(statements[0]); MessageLog.log("query", "info", `SQL: ${statements[0].substring(0, 80)}`, { rows: rows.length }); res.json({ rows, count: rows.length }); return; } } // Execute all statements (single write or multi-statement batch) let totalAffected = 0; db.startTransaction(); try { for (const stmt of statements) { const result = db.execute(stmt); // execute may return an object with affected count or a number if (typeof result === "number") { totalAffected += result; } else if (result && typeof result === "object" && "changes" in (result as Record)) { totalAffected += Number((result as Record).changes) || 0; } } db.commit(); } catch (e: unknown) { db.rollback(); const msg = e instanceof Error ? e.message : String(e); res.json({ error: msg }); return; } MessageLog.log("query", "warn", `SQL batch: ${statements.length} statement(s)`, { affected: totalAffected }); res.json({ affected: totalAffected, success: true }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); res.json({ error: msg }); } }; // -- Broken (errors) handlers -- const handleBroken: RouteHandler = (_req, res) => { const errors = ErrorTracker.get(); const unresolved = errors.filter((e) => !e.resolved).length; // Map to shared JS format: error_type, message, traceback, count, last_seen const mappedErrors = errors.map((e) => ({ id: e.id, error_type: "Error", message: e.message, traceback: e.stack ?? "", count: 1, last_seen: e.timestamp, resolved: e.resolved, })); res.json({ errors: mappedErrors, health: { unresolved }, count: errors.length }); }; const handleBrokenResolve: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const id = url.searchParams.get("id") ?? (req as any).body?.id ?? ""; if (!id) { res.json({ error: "Missing error id" }); return; } const resolved = ErrorTracker.resolve(id); res.json({ resolved }); }; const handleBrokenClear: RouteHandler = (_req, res) => { // "Clear All" button — flush every tracked error, not only the // ones individually marked resolved. Matches PHP/Python/Ruby. ErrorTracker.clearAll(); res.json({ cleared: true }); }; // -- WebSocket handlers -- const handleWebsockets: RouteHandler = (_req, res) => { const conns = WsTracker.list(); // Map to shared JS format: ip, connected_at, closed const mapped = conns.map((c) => ({ id: c.id, path: c.path, ip: c.remoteAddress, connected_at: c.connectedAt, closed: false, })); res.json({ connections: mapped, count: mapped.length }); }; const handleWebsocketsDisconnect: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const id = url.searchParams.get("id") ?? (req as any).body?.id ?? ""; if (!id) { res.json({ error: "Missing connection id" }); return; } const removed = WsTracker.remove(id); res.json({ disconnected: removed }); }; // -- Tool handler -- const handleTool: RouteHandler = (req, res) => { const tool = (req as any).body?.tool ?? ""; const validTools = ["test", "migrate", "seed", "routes", "carbon", "ai"]; if (!tool || !validTools.includes(tool)) { res.json({ error: `Invalid tool. Valid tools: ${validTools.join(", ")}` }); return; } // Stub response — actual implementations will be wired in later res.json({ tool, status: "executed", message: `Tool '${tool}' executed (stub)`, timestamp: new Date().toISOString() }); }; // -- Supervisor proxy helpers -- /** * Return the base URL for the co-located Rust agent server. * * Mirrors Python's `_supervisor_base_url()` in * `tina4_python/dev_admin/__init__.py`. Resolution order: * 1. `TINA4_SUPERVISOR_URL` — explicit full URL. * 2. `TINA4_AGENT_PORT` — explicit port on 127.0.0.1. * 3. `PORT` + 2000 — auto-derived (matches `tina4 serve` agent port). * 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`. */ export function supervisorBaseUrl(): string { const explicit = (process.env.TINA4_SUPERVISOR_URL ?? "").replace(/\/+$/, ""); if (explicit) return explicit; const agentPort = (process.env.TINA4_AGENT_PORT ?? "").trim(); if (/^\d+$/.test(agentPort)) return `http://127.0.0.1:${parseInt(agentPort, 10)}`; const fwPort = (process.env.PORT ?? "").trim(); if (/^\d+$/.test(fwPort)) return `http://127.0.0.1:${parseInt(fwPort, 10) + 2000}`; return "http://127.0.0.1:9145"; } /** * Forward a dev-admin request to the Rust agent server. * * Mirrors Python's `_proxy_to_supervisor()`. Strips the `/__dev/api` prefix, * forwards method/body/query verbatim to `{downstreamPath}`, and pipes * the response back. SSE (`text/event-stream`) is streamed chunk-by-chunk so * progress events reach the SPA live instead of after the full multi-agent * run completes. When the agent is unreachable we respond with 503 and a * hint so the SPA can show a useful error. */ async function proxyToSupervisor( req: any, res: any, downstreamPath: string, ): Promise { const base = supervisorBaseUrl(); // Forward query string verbatim let qs = ""; try { const reqUrl = new URL(req.url ?? "/", "http://localhost"); if (reqUrl.search) qs = reqUrl.search; } catch { /* ignore */ } const target = `${base}${downstreamPath}${qs}`; const method = (req.method ?? "GET").toUpperCase(); // Build the body for methods that carry one let bodyText: string | undefined; if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") { const body = (req as any).body; if (body !== undefined && body !== null) { if (typeof body === "string") { bodyText = body; } else if (typeof body === "object") { // SPA→agent convention fixup (matches Python): `/execute` sends // plan_file as a bare filename but the rust agent expects a // project-relative path. Prepend `plan/` when no slash is present. let outBody: any = body; if (!Array.isArray(body)) { const pf = (body as any).plan_file; if (typeof pf === "string" && pf && !pf.includes("/")) { outBody = { ...body, plan_file: `plan/${pf}` }; } } bodyText = JSON.stringify(outBody); } } } // Heavy multi-agent endpoints get a generous timeout; metadata-only // /supervise/* and /threads/* calls return fast. const timeoutMs = downstreamPath === "/execute" || downstreamPath === "/chat" ? 600_000 : 30_000; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); let upstream: Response; try { upstream = await fetch(target, { method, headers: { "Content-Type": "application/json" }, body: bodyText, signal: ctrl.signal, }); } catch (e) { clearTimeout(timer); res.json( { error: "supervisor unavailable", detail: (e as Error).message, hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL", }, 503, ); return; } const ct = (upstream.headers.get("content-type") ?? "").toLowerCase(); // SSE / event-stream — stream chunks through as they arrive. if (ct.includes("text/event-stream")) { res.raw.writeHead(upstream.status || 200, { "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); if (typeof (res.raw as any).flushHeaders === "function") { (res.raw as any).flushHeaders(); } if (!upstream.body) { res.raw.end(); clearTimeout(timer); return; } const reader = upstream.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) res.raw.write(Buffer.from(value)); } } finally { clearTimeout(timer); res.raw.end(); } return; } clearTimeout(timer); // JSON / other — drain the body and return as before. const raw = await upstream.text(); const status = upstream.status || 200; try { res.json(JSON.parse(raw), status); } catch { // Non-JSON upstream — pass through as text with the same status. res.raw.writeHead(status, { "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8", }); res.raw.end(raw); } } // -- Framework-grounding (mcp.tina4.com) token config -- // // TINA4_MCP_TOKEN grounds the coder against mcp.tina4.com's tina4_context. // These routes are self-contained: they read/write the token in the project // .env directly (no dependency on the Rust agent being up), matching the // python/php/ruby dev_admin. The agent still resolves the same TINA4_MCP_TOKEN // (process env → .env) when it runs. // GET /__dev/api/grounding/status → {configured, last4, url} // POST /__dev/api/grounding/token → upsert TINA4_MCP_TOKEN in .env const DEFAULT_MCP_URL = "https://mcp.tina4.com"; /** Resolve a var from the live process env, falling back to the project .env. */ function resolveDevEnvVar(key: string): string { const live = process.env[key]; if (live !== undefined && live !== "") return live; const envPath = join(process.cwd(), ".env"); if (!existsSync(envPath)) return ""; for (const line of readFileSync(envPath, "utf-8").split("\n")) { const t = line.trim(); if (!t || t.startsWith("#") || !t.includes("=")) continue; const eq = t.indexOf("="); if (t.slice(0, eq).trim() === key) return t.slice(eq + 1).trim(); } return ""; } /** Upsert KEY=value into the project .env (creates the file if absent). */ function upsertDevEnvVar(key: string, value: string): void { const envPath = join(process.cwd(), ".env"); const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : []; let found = false; const out: string[] = []; for (const line of lines) { const t = line.trim(); if (!t || t.startsWith("#") || !t.includes("=")) { out.push(line); continue; } if (t.slice(0, t.indexOf("=")).trim() === key) { out.push(`${key}=${value}`); found = true; } else out.push(line); } if (!found) out.push(`${key}=${value}`); writeFileSync(envPath, out.join("\n").replace(/\n+$/, "") + "\n"); } const handleGroundingStatus: RouteHandler = async (_req, res) => { const token = resolveDevEnvVar("TINA4_MCP_TOKEN"); const url = resolveDevEnvVar("TINA4_MCP_URL") || DEFAULT_MCP_URL; res.json({ configured: token.length > 0, last4: token ? token.slice(-4) : "", url }); }; const handleGroundingToken: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const token = String(body.token ?? "").trim(); try { upsertDevEnvVar("TINA4_MCP_TOKEN", token); // Reflect into the live process env so a follow-up status reads back at once. process.env.TINA4_MCP_TOKEN = token; res.json({ ok: true, configured: token.length > 0, last4: token ? token.slice(-4) : "" }); } catch (e) { res.json({ ok: false, error: (e as Error).message }, 500); } }; // -- Scaffold run chips: migrate / test / seed-all -- // // The dev-admin's ▶ Migrate / ▶ Test / ▶ Seed chips are project-level // operations, distinct from the create endpoint (/scaffold/run). Each uses the // framework's own machinery so behaviour matches the CLI. /** POST /__dev/api/migrate — apply pending migrations via the ORM's migrate(). */ const handleMigrate: RouteHandler = async (_req, res) => { try { const orm = await import("../../orm/src/index.js"); const result = await orm.migrate(); res.json({ ok: result.failed.length === 0, applied: result.applied, skipped: result.skipped, failed: result.failed, }); } catch (e) { res.json({ ok: false, error: (e as Error).message }, 500); } }; /** POST /__dev/api/seed/run — seed every discovered model, FK-ordered. */ const handleSeedRun: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const count = parseInt(String(body.count ?? "10"), 10) || 10; try { const orm = await import("../../orm/src/index.js"); // Models live in src/orm/ (primary) and src/models/ (fallback) — same dirs // the server discovers on startup. const dirs = ["src/orm", "src/models"].map((d) => resolve(process.cwd(), d)).filter((d) => existsSync(d)); const classes: unknown[] = []; for (const dir of dirs) { for (const m of await orm.discoverModels(dir)) classes.push(m.modelClass); } if (classes.length === 0) { res.json({ ok: false, error: "No models found in src/orm/ or src/models/" }, 400); return; } const summaries = await orm.seedModels(classes as never[], count); let seeded = 0, failed = 0; for (const s of Object.values(summaries) as Array<{ seeded: number; failed: number }>) { seeded += s.seeded; failed += s.failed; } res.json({ ok: failed === 0, seeded, failed, tables: Object.keys(summaries).length }); } catch (e) { res.json({ ok: false, error: (e as Error).message }, 500); } }; /** POST /__dev/api/test — run the project test suite (`npm test`) and stream back * the combined output + exit code. Bounded timeout so a hung suite can't wedge * the dev server. */ const handleTest: RouteHandler = async (_req, res) => { try { const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const run = promisify(execFile); try { const { stdout, stderr } = await run("npm", ["test"], { cwd: resolve(process.cwd()), timeout: 180_000, encoding: "utf-8", maxBuffer: 8 * 1024 * 1024, }); res.json({ ok: true, code: 0, output: `${stdout}${stderr}` }); } catch (err) { // Non-zero exit (failing tests) lands here — that's a valid, reportable // result, not a 500. Surface the output + code so the UI shows failures. const e = err as { code?: number; stdout?: string; stderr?: string; message?: string }; res.json({ ok: false, code: typeof e.code === "number" ? e.code : 1, output: `${e.stdout ?? ""}${e.stderr ?? ""}` || e.message || "tests failed", }); } } catch (e) { res.json({ ok: false, error: (e as Error).message }, 500); } }; // -- Threads handlers -- /** * Proxy /__dev/api/threads → Rust agent /threads. * GET → list threads * POST → create thread * Method-multiplexed — anything else gets a 405. */ const handleThreads: RouteHandler = async (req, res) => { const method = (req.method ?? "GET").toUpperCase(); if (method !== "GET" && method !== "POST") { res.json({ error: "method not allowed" }, 405); return; } await proxyToSupervisor(req, res, "/threads"); }; /** * Proxy /__dev/api/threads/{id}[/messages] → Rust agent. * * Strips the dev-admin prefix and forwards the remaining path verbatim so * /__dev/api/threads/abc/messages becomes /threads/abc/messages on the * agent side. Mirrors Python's `_api_threads_sub`. */ const handleThreadsSub: RouteHandler = async (req, res) => { let pathname = ""; try { pathname = new URL(req.url ?? "/", "http://localhost").pathname; } catch { pathname = req.url ?? ""; } const prefix = "/__dev/api"; if (!pathname.startsWith(prefix)) { res.json({ error: "not found" }, 404); return; } const suffix = pathname.slice(prefix.length); // "/threads/abc[/messages]" if (!suffix.startsWith("/threads/")) { res.json({ error: "not found" }, 404); return; } await proxyToSupervisor(req, res, suffix); }; // --------------------------------------------------------------------------- function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const parts: string[] = []; if (d > 0) parts.push(`${d}d`); if (h > 0) parts.push(`${h}h`); if (m > 0) parts.push(`${m}m`); parts.push(`${s}s`); return parts.join(" "); } // --------------------------------------------------------------------------- // Connection helpers // --------------------------------------------------------------------------- function parseEnvFile(): Record { const envPath = join(process.cwd(), ".env"); const result: Record = {}; if (!existsSync(envPath)) return result; const lines = readFileSync(envPath, "utf-8").split("\n"); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue; const [key, ...rest] = trimmed.split("="); result[key.trim()] = rest.join("=").trim().replace(/^["']|["']$/g, ""); } return result; } const handleConnections: RouteHandler = (_req, res) => { const env = parseEnvFile(); res.json({ url: env.TINA4_DATABASE_URL ?? "", username: env.TINA4_DATABASE_USERNAME ?? "", password: env.TINA4_DATABASE_PASSWORD ? "***" : "", }); }; const handleConnectionsTest: RouteHandler = async (req, res) => { const body = req.body as Record | undefined; const url = body?.url ?? ""; const username = body?.username ?? ""; const password = body?.password ?? ""; if (!url) { res.json({ success: false, error: "No connection URL provided" }); return; } try { // Try to use the ORM's initDatabase if available const { initDatabase } = await import("../../orm/src/index.js").catch(() => ({ initDatabase: null })); if (!initDatabase) { res.json({ success: false, error: "Database module (@tina4/orm) not available" }); return; } const db = await initDatabase({ url, username, password }); let version = "Connected"; let tableCount = 0; try { // getTables() is async (Promise) and MUST be awaited — the // un-awaited call made tableCount always 0 (Array.isArray(Promise) is // false). Python (db.get_tables()) and Ruby (db.tables) were correct. const tables = await db.getTables(); tableCount = Array.isArray(tables) ? tables.length : 0; } catch { tableCount = 0; } try { const urlLower = url.toLowerCase(); // fetchOne() is async and returns a single row object ({ v: ... }); it // must be awaited. Previously db.execute() was left un-awaited, so `row` // was a Promise and the version always fell back to the default string. // Mirrors Python/Ruby which read row["v"] from a fetch_one(). if (urlLower.includes("sqlite")) { const row = await db.fetchOne<{ v?: string }>("SELECT sqlite_version() as v"); version = `SQLite ${row?.v ?? ""}`; } else if (urlLower.includes("postgres")) { const row = await db.fetchOne<{ v?: string }>("SELECT version() as v"); version = (row?.v ?? "PostgreSQL").toString().split(",")[0]; } else if (urlLower.includes("mysql")) { const row = await db.fetchOne<{ v?: string }>("SELECT version() as v"); version = `MySQL ${row?.v ?? ""}`; } else if (urlLower.includes("mssql")) { const row = await db.fetchOne<{ v?: string }>("SELECT @@VERSION as v"); version = (row?.v ?? "MSSQL").toString().split("\n")[0]; } else if (urlLower.includes("firebird")) { const row = await db.fetchOne<{ v?: string }>("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database"); version = `Firebird ${row?.v ?? ""}`; } } catch { /* keep version as Connected */ } db.close(); res.json({ success: true, version, tables: tableCount }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); res.json({ success: false, error: msg }); } }; const handleConnectionsSave: RouteHandler = (req, res) => { const body = req.body as Record | undefined; const url = body?.url ?? ""; const username = body?.username ?? ""; const password = body?.password ?? ""; if (!url) { res.json({ success: false, error: "No connection URL provided" }); return; } try { const envPath = join(process.cwd(), ".env"); const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : []; const keysFound: Record = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false }; const newLines: string[] = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { newLines.push(line); continue; } const key = trimmed.split("=", 1)[0].trim(); if (key === "TINA4_DATABASE_URL") { newLines.push(`TINA4_DATABASE_URL=${url}`); keysFound.TINA4_DATABASE_URL = true; } else if (key === "TINA4_DATABASE_USERNAME") { newLines.push(`TINA4_DATABASE_USERNAME=${username}`); keysFound.TINA4_DATABASE_USERNAME = true; } else if (key === "TINA4_DATABASE_PASSWORD") { newLines.push(`TINA4_DATABASE_PASSWORD=${password}`); keysFound.TINA4_DATABASE_PASSWORD = true; } else { newLines.push(line); } } const values: Record = { TINA4_DATABASE_URL: url, TINA4_DATABASE_USERNAME: username, TINA4_DATABASE_PASSWORD: password }; for (const [key, found] of Object.entries(keysFound)) { if (!found) newLines.push(`${key}=${values[key]}`); } writeFileSync(envPath, newLines.join("\n") + "\n"); res.json({ success: true }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); res.json({ success: false, error: msg }); } }; // --------------------------------------------------------------------------- // Gallery handlers — list and deploy gallery examples // --------------------------------------------------------------------------- const __devAdminFilename = fileURLToPath(import.meta.url); const __devAdminDirname = dirname(__devAdminFilename); function walkDirRecursive(dir: string): string[] { const results: string[] = []; if (!existsSync(dir)) return results; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { results.push(...walkDirRecursive(full)); } else { results.push(full); } } return results; } const handleGalleryList: RouteHandler = (_req, res) => { const galleryDir = resolve(__devAdminDirname, "..", "gallery"); const items: Array> = []; if (existsSync(galleryDir)) { const entries = readdirSync(galleryDir).sort(); for (const entry of entries) { const entryPath = join(galleryDir, entry); const metaFile = join(entryPath, "meta.json"); if (statSync(entryPath).isDirectory() && existsSync(metaFile)) { try { const meta = JSON.parse(readFileSync(metaFile, "utf-8")); meta.id = entry; // List the files that would be deployed const srcDir = join(entryPath, "src"); if (existsSync(srcDir)) { const allFiles = walkDirRecursive(srcDir); meta.files = allFiles.map((f) => relative(srcDir, f)); } // Check if already deployed const projectSrc = resolve(process.cwd(), "src"); if (existsSync(srcDir) && meta.files) { meta.deployed = (meta.files as string[]).every((f: string) => existsSync(join(projectSrc, f)), ); } else { meta.deployed = false; } items.push(meta); } catch { // Skip invalid meta.json } } } } res.json({ gallery: items, count: items.length }); }; function handleGalleryDeploy(router: Router): RouteHandler { return async (req, res): Promise => { const body = (req.body as Record) ?? {}; const name = (body.name as string) ?? ""; if (!name) { res.json({ error: "No gallery item specified" }, 400); return; } const galleryDir = resolve(__devAdminDirname, "..", "gallery"); const gallerySrc = join(galleryDir, name, "src"); if (!existsSync(gallerySrc)) { res.json({ error: `Gallery item '${name}' not found` }, 404); return; } const projectSrc = resolve(process.cwd(), "src"); const copied: string[] = []; const allFiles = walkDirRecursive(gallerySrc); for (const srcFile of allFiles) { const rel = relative(gallerySrc, srcFile); const dest = join(projectSrc, rel); mkdirSync(dirname(dest), { recursive: true }); copyFileSync(srcFile, dest); copied.push(rel); } // Re-discover routes so new files are immediately available try { const routesDir = resolve(process.cwd(), "src", "routes"); if (existsSync(routesDir)) { const { discoverRoutes } = await import("./routeDiscovery.js"); const routes = await discoverRoutes(routesDir); for (const route of routes) { // Only add if not already registered const existing = router.match(route.method, route.pattern.replace(/\{(\w+)\}/g, "test").replace(/\{\.\.\.\w+\}/g, "test")); if (!existing) { router.addRoute(route); } } } } catch { // Non-fatal — routes will load on next restart } res.json({ deployed: name, files: copied }); }; } // --------------------------------------------------------------------------- // Version check — proxy to npm registry to avoid browser CORS errors // --------------------------------------------------------------------------- /** * Version check — a check that did not happen says so. * * This used to fall back to `latest = current` on any failure, and the toolbar * renders that as a green "You are up to date!" — so a developer several * releases behind, on a machine with no route out, was told the opposite of the * truth, and the toolbar's own "Could not check for updates" branch could never * fire because the failure arrived as a success. `latest` is `null` when the * check could not be made, and `error` says why. The registry URL is * `TINA4_VERSION_CHECK_URL` when set (a mirror, or a test's own server), else * npm. Mirrors Python `tina4_python.dev_admin._api_version_check`. */ export const handleVersionCheck: RouteHandler = async (_req, res) => { const current = TINA4_VERSION; const url = process.env.TINA4_VERSION_CHECK_URL || "https://registry.npmjs.org/tina4-nodejs/latest"; const failed = (why: string) => res.json({ current, latest: null, error: why }); let data: Record; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const resp = await fetch(url, { signal: controller.signal, headers: { "User-Agent": `tina4-nodejs/${current}` }, }); // Reaching the registry is not the same as a 200 with a body. if (!resp.ok) return failed(`npm registry responded ${resp.status}`); data = (await resp.json()) as Record; } finally { clearTimeout(timer); } } catch (exc) { // offline, timeout, DNS, unreadable body return failed(exc instanceof Error && exc.message ? exc.message : String(exc)); } // An answer we cannot read a version out of is the same lie by another route. const latest = typeof data.version === "string" ? data.version : ""; if (!latest) return failed("npm registry did not report a version"); return res.json({ current, latest }); }; // --------------------------------------------------------------------------- // Parity handlers — ported from Python tina4_python.dev_admin // --------------------------------------------------------------------------- function safeJoin(projectRoot: string, rel: string): string | null { const resolved = resolve(projectRoot, rel); if (!resolved.startsWith(projectRoot)) return null; return resolved; } const handleThoughts: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const limit = parseInt(url.searchParams.get("limit") ?? "100", 10); const entries = MessageLog.get(undefined, limit).map((e) => ({ id: e.id, timestamp: e.timestamp, level: e.level, category: e.category, message: e.message, data: e.data, })); res.json({ thoughts: entries, count: entries.length }); }; const handleSuperviseStub: RouteHandler = (_req, res) => { res.json( { error: "supervise API not implemented in tina4-nodejs yet", note: "Requires Rust agent + worktree manager for parity with Python/PHP. Stubbed intentionally.", }, 501, ); }; // --- file-browser noise filter + git decoration (mirrors PHP/Python dev-admin) --- const DEV_FILES_IGNORED = new Set([ "__pycache__", "node_modules", "vendor", ".git", "venv", ".venv", "dist", "target", ".tina4", ]); // Hidden dot-entries are filtered too, except the env files. function devFilesHidden(name: string): boolean { if (DEV_FILES_IGNORED.has(name)) return true; // DEVADMIN-DEC-03: hide every dotfile EXCEPT the safe `.env.example` template. // `.env` is a secret (TINA4_SECRET / DB password / TINA4_MCP_TOKEN) and must // NOT be surfaced in the browser (it used to be un-hidden here). return name.startsWith(".") && name !== ".env.example"; } // Same 4-status mapping Python/PHP use for a porcelain code. function devGitStatusLabel(code: string): string { if (code === "??") return "untracked"; if (code.includes("M")) return "modified"; if (code.includes("A")) return "added"; if (code.includes("D")) return "deleted"; return "clean"; } /** * Branch + porcelain status map for the file browser, mirroring PHP's * devAdminGit* helpers 1:1. Paths git reports are relative to the repo * root (always forward-slash); the project root may sit inside a larger * repo (monorepo), so the toplevel is returned too for rebasing. Degrades * to empty (every entry "clean") on any error / no git. */ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: string | null; status: Map }> { const empty = { branch: "", gitRoot: null as string | null, status: new Map() }; try { const { execFileSync } = await import("node:child_process"); const git = (args: string[]): string | null => { try { return execFileSync("git", args, { cwd: root, timeout: 3000, encoding: "utf-8" }).toString(); } catch { return null; } }; const inside = git(["rev-parse", "--is-inside-work-tree"]); if (!inside || inside.trim() !== "true") return empty; const branch = (git(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "").trim(); const topRaw = git(["rev-parse", "--show-toplevel"]); const gitRoot = topRaw && topRaw.trim() !== "" ? topRaw.trim().replace(/\\/g, "/").replace(/\/+$/, "") : null; const status = new Map(); const porcelain = git(["status", "--porcelain", "-uall"]); if (porcelain) { for (const line of porcelain.split(/\r?\n/)) { if (line.length < 4) continue; const code = line.slice(0, 2).trim(); let p = line.slice(3).trim(); const arrow = p.indexOf(" -> "); // rename/copy — keep destination if (arrow !== -1) p = p.slice(arrow + 4); if (p === "") continue; status.set(p, code); } } return { branch, gitRoot, status }; } catch { return empty; } } const handleFiles: RouteHandler = async (req, res) => { // Response shape matches tina4-python / tina4-php 1:1 so the dev-admin SPA // works against every framework with no branching: each entry carries // `is_dir`, `has_children`, `git_status` and `size`; the payload carries the // git `branch`. Noise dirs + hidden dot-files (except .env/.env.example) are // filtered out. const url = new URL(req.url ?? "/", "http://localhost"); const rel = url.searchParams.get("path") ?? "."; const root = resolve(process.cwd()); const target = safeJoin(root, rel); const { branch, gitRoot, status: gitStatus } = await devGitInfo(root); // Missing/invalid paths return an empty-but-valid shape (not 404): the SPA // restores expanded-folder state from localStorage, and folders that don't // exist in this harness would otherwise spam the console with red 404s. if (!target || !existsSync(target) || !statSync(target).isDirectory()) { res.json({ path: rel, branch, entries: [], error: "not a directory" }); return; } // Rebase entry paths onto the git repo root when the project sits inside a // larger repo. Everything runs in forward-slash form. const rootFwd = root.replace(/\\/g, "/"); let cwdInGit = ""; if (gitRoot && gitRoot !== rootFwd && rootFwd.startsWith(gitRoot)) { cwdInGit = rootFwd.slice(gitRoot.length).replace(/^\/+/, ""); if (cwdInGit !== "") cwdInGit += "/"; } const entries: Array> = []; for (const name of readdirSync(target).sort()) { // alphabetical; no re-sort by type if (devFilesHidden(name)) continue; const full = join(target, name); const entryRel = relative(root, full).replace(/\\/g, "/"); // DEVADMIN-DEC-03: never surface secrets in the listing (.env, keys, .git/, // secrets/). The .env.example template is safe and stays visible. if (isSecretPath(entryRel)) continue; let isDir = false; let size: number | null = null; try { const st = statSync(full); isDir = st.isDirectory(); if (!isDir) size = st.size; } catch { /* unreadable entry */ } // git status for this entry (same mapping PHP/Python use) const gitPath = cwdInGit + entryRel; let gitLabel = "clean"; const code = gitStatus.get(gitPath); if (code !== undefined) { gitLabel = devGitStatusLabel(code); } else if (isDir) { const prefix = gitPath + "/"; // propagate dirty status from any child for (const [gf, gc] of gitStatus) { if (gf.startsWith(prefix)) { gitLabel = gc === "??" ? "untracked" : "modified"; break; } } } // has_children: does the dir contain anything visible? let hasChildren: boolean | null = null; if (isDir) { hasChildren = false; try { for (const c of readdirSync(full)) { if (devFilesHidden(c)) continue; hasChildren = true; break; } } catch { /* ignore */ } } entries.push({ name, path: entryRel, is_dir: isDir, has_children: hasChildren, git_status: gitLabel, size, }); } res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries }); }; // Canonical extension→language map. Kept identical in coverage to the Python // master (tina4_python/tina4_python/dev_admin/__init__.py `lang_map`) and the // PHP/Ruby file-read endpoints. The dev-admin SPA maps the returned "language" // string to a CodeMirror grammar for syntax highlighting. const DEV_ADMIN_LANG_MAP: Record = { ".py": "python", ".php": "php", ".rb": "ruby", ".ts": "typescript", ".js": "javascript", ".jsx": "javascript", ".tsx": "typescript", ".json": "json", ".html": "html", ".twig": "html", ".css": "css", ".scss": "css", ".md": "markdown", ".sql": "sql", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".xml": "html", ".env": "env", ".env.example": "env", ".sh": "shell", ".bash": "shell", ".bat": "shell", ".cmd": "shell", ".ps1": "shell", ".rs": "rust", ".go": "go", ".java": "java", ".txt": "text", ".csv": "text", ".log": "text", ".gemspec": "ruby", ".rake": "ruby", ".svg": "svg", }; /** * Resolve a CodeMirror-friendly language id from a file path's basename. * * - `Dockerfile` / `Dockerfile.dev` / `Dockerfile.prod` (no extension) → "dockerfile" * - `.env.example` (two-part) and `.env` → "env" * - otherwise the file extension is looked up in DEV_ADMIN_LANG_MAP * - anything unknown → "text" */ export function devAdminLanguage(rel: string): string { const base = (rel.split(/[\\/]/).pop() ?? "").toLowerCase(); if (base === "dockerfile" || base === "dockerfile.dev" || base === "dockerfile.prod") { return "dockerfile"; } // Two-part extension first (e.g. ".env.example"), then the single extension. if (base.endsWith(".env.example")) return DEV_ADMIN_LANG_MAP[".env.example"]; const dot = base.lastIndexOf("."); // A leading dot with no other dot is a dotfile name, not an extension // (e.g. ".env" → ext ".env"); only treat as "no extension" when there's no dot. if (dot < 0) return "text"; const ext = base.slice(dot); return DEV_ADMIN_LANG_MAP[ext] ?? "text"; } const handleFileRead: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const rel = url.searchParams.get("path") ?? ""; // DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/). if (isSecretPath(rel)) { res.json({ error: "Refused: secret file", path: rel, content: "", language: "text", bytes: 0 }, 403); return; } const root = resolve(process.cwd()); const target = safeJoin(root, rel); if (!target || !existsSync(target) || !statSync(target).isFile()) { res.json({ error: `File not found: ${rel}` }, 404); return; } try { const content = readFileSync(target, "utf-8"); const path = relative(root, target); res.json({ path, content, language: devAdminLanguage(path), bytes: Buffer.byteLength(content, "utf-8") }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleFileSave: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const rel = (body.path as string) || ""; const content = (body.content as string) ?? ""; const root = resolve(process.cwd()); const target = safeJoin(root, rel); if (!target) { res.json({ error: `Path escapes project directory: ${rel}` }, 400); return; } try { mkdirSync(dirname(target), { recursive: true }); const existed = existsSync(target); writeFileSync(target, content, "utf-8"); try { const { Plan } = await import("./plan.js"); Plan.recordAction(existed ? "patched" : "created", relative(root, target)); } catch { /* ignore */ } res.json({ ok: true, path: relative(root, target), bytes: Buffer.byteLength(content, "utf-8") }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleFileRaw: RouteHandler = (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const rel = url.searchParams.get("path") ?? ""; // DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/). if (isSecretPath(rel)) { res.json({ error: "Refused: secret file" }, 403); return; } const root = resolve(process.cwd()); const target = safeJoin(root, rel); if (!target || !existsSync(target) || !statSync(target).isFile()) { res.raw.writeHead(404); res.raw.end("Not found"); return; } try { const buf = readFileSync(target); const ext = target.slice(target.lastIndexOf(".") + 1).toLowerCase(); const mime: Record = { js: "application/javascript", ts: "text/plain", json: "application/json", html: "text/html", css: "text/css", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", md: "text/markdown", txt: "text/plain", }; res.raw.writeHead(200, { "Content-Type": mime[ext] || "application/octet-stream" }); res.raw.end(buf); } catch (e) { res.raw.writeHead(500); res.raw.end((e as Error).message); } }; const handleFileRename: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const from = (body.from as string) || ""; const to = (body.to as string) || ""; const root = resolve(process.cwd()); const src = safeJoin(root, from); const dst = safeJoin(root, to); if (!src || !dst) { res.json({ error: "Invalid path" }, 400); return; } if (!existsSync(src)) { res.json({ error: `Source not found: ${from}` }, 404); return; } try { const { renameSync } = await import("node:fs"); mkdirSync(dirname(dst), { recursive: true }); renameSync(src, dst); res.json({ ok: true, from: relative(root, src), to: relative(root, dst) }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleFileDelete: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const rel = (body.path as string) || ""; const root = resolve(process.cwd()); const target = safeJoin(root, rel); if (!target) { res.json({ error: "Invalid path" }, 400); return; } if (!existsSync(target)) { res.json({ error: `Not found: ${rel}` }, 404); return; } try { const { rmSync } = await import("node:fs"); rmSync(target, { recursive: true, force: true }); res.json({ ok: true, deleted: relative(root, target) }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleDepsSearch: RouteHandler = async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const q = url.searchParams.get("q") ?? ""; if (!q) { res.json({ error: "q required" }, 400); return; } try { const r = await fetch(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=20`); const data = (await r.json()) as { objects?: Array> }; const results = (data.objects || []).map((o) => ({ name: o.package?.name, version: o.package?.version, description: o.package?.description, links: o.package?.links, })); res.json({ results }); } catch (e) { res.json({ error: (e as Error).message }, 502); } }; const handleDepsInstall: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const pkg = (body.package as string) || ""; const dev = Boolean(body.dev); if (!pkg || !/^[@A-Za-z0-9][\w@/.\-]*$/.test(pkg)) { res.json({ error: "invalid package name" }, 400); return; } try { const { execFileSync } = await import("node:child_process"); const args = ["install", dev ? "--save-dev" : "--save", pkg]; const output = execFileSync("npm", args, { cwd: resolve(process.cwd()), timeout: 120_000, encoding: "utf-8", }).toString(); res.json({ ok: true, package: pkg, output }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleGitStatus: RouteHandler = async (_req, res) => { try { const { execFileSync } = await import("node:child_process"); const cwd = resolve(process.cwd()); try { execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 3000 }); } catch { res.json({ error: "Not a git repository" }, 400); return; } const run = (args: string[]): string => execFileSync("git", args, { cwd, timeout: 3000, encoding: "utf-8" }).toString().trim(); res.json({ branch: run(["branch", "--show-current"]), status: run(["status", "--porcelain"]).split(/\r?\n/).filter((l) => l), recent_commits: run(["log", "--oneline", "-5"]).split(/\r?\n/).filter((l) => l), }); } catch (e) { res.json({ error: `git unavailable: ${(e as Error).message}` }, 500); } }; /** Constant-time string compare (length-guarded so timingSafeEqual never throws). */ function mcpSecureEqual(expected: string, provided: string): boolean { const a = Buffer.from(expected); const b = Buffer.from(provided); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } /** * Whether the request carried a token matching TINA4_MCP_TOKEN (fallback * TINA4_API_KEY). Transports: Authorization Bearer / X-MCP-Token / X-Api-Key. * No configured token ⇒ a remote caller can never present a valid one. */ function mcpTokenOk(req: Tina4Request): boolean { let expected = process.env.TINA4_MCP_TOKEN; if (!expected) expected = process.env.TINA4_API_KEY; if (!expected) return false; let provided = ""; const auth = req.header("authorization") ?? ""; if (auth.toLowerCase().startsWith("bearer ")) provided = auth.slice(7).trim(); if (!provided) provided = req.header("x-mcp-token") ?? ""; if (!provided) provided = req.header("x-api-key") ?? ""; if (!provided) return false; return mcpSecureEqual(expected, provided); } /** * Per-request MCP authorisation using the RAW socket peer (never X-Forwarded-For, * which is spoofable). Loopback is always allowed; a remote caller needs * TINA4_MCP_REMOTE=true plus a valid token. Mirrors the Python/PHP/Ruby gate. */ function mcpRequestAllowed(req: Tina4Request): boolean { const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? ""; return isRequestAllowed(peer, mcpTokenOk(req)); } const handleMcpTools: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ tools: [], error: "MCP forbidden" }, 404); return; } try { // Ensure the default /__dev/mcp server exists with its dev tools registered, // then enumerate every registered MCP server instance (app-defined servers // register themselves on construction too). const { McpServer, getDefaultDevServer } = await import("./mcp.js"); getDefaultDevServer(); const instances = (McpServer as unknown as { _instances: Array })._instances || []; const tools: Array<{ server: string; name: string; description: string; inputSchema: unknown }> = []; for (const s of instances) { for (const t of ((s as any)._tools as Map).values()) { tools.push({ server: s.name, name: t.name, description: t.description, inputSchema: t.inputSchema }); } } res.json({ tools, count: tools.length }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; const handleMcpCall: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } const body = (req.body as Record) || {}; const name = (body.name as string) || ""; const args = (body.arguments as Record) || {}; try { const { McpServer, getDefaultDevServer } = await import("./mcp.js"); getDefaultDevServer(); const instances = (McpServer as unknown as { _instances: Array })._instances || []; for (const s of instances) { const tool = ((s as any)._tools as Map).get(name); if (tool) { const result = await tool.handler(args); res.json({ ok: true, result }); return; } } res.json({ error: `Unknown tool: ${name}` }, 404); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; /** * JSON-RPC message endpoint for real MCP clients. * * Mounted at POST /__dev/mcp (Streamable HTTP). Forwards the body to the default * dev MCP server via dispatchHttp(): initialize issues an Mcp-Session-Id header, * an unknown session id is 404 (client re-inits), a notification is 202, else * 200 with the JSON-RPC response as application/json. The /__dev path is always * public (auth-bypassed), so MCP clients connect without a token. */ function mcpNormalizeBody(body: unknown): string | Record { if (typeof body === "object" && body !== null) return body as Record; if (typeof body === "string") return body; return String(body ?? ""); } function applyMcpOutcome( res: Parameters[1], outcome: { status: number; headers: Record; body: string }, ): void { for (const [n, v] of Object.entries(outcome.headers)) res.addHeader(n, v); if (!outcome.body) { res.send("", outcome.status); return; } res.json(JSON.parse(outcome.body), outcome.status); } const handleMcpStreamable: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } try { const { getDefaultDevServer } = await import("./mcp.js"); const server = getDefaultDevServer(); const sessionId = req.header("mcp-session-id") ?? ""; applyMcpOutcome(res, await server.dispatchHttp(mcpNormalizeBody(req.body), sessionId)); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; /** DELETE /__dev/mcp — terminate the session named by Mcp-Session-Id. */ const handleMcpDelete: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } const { getDefaultDevServer } = await import("./mcp.js"); getDefaultDevServer().closeSession(req.header("mcp-session-id") ?? ""); res.send("", 204); }; /** GET /__dev/mcp — 405: this server initiates no messages (use /sse for a stream). */ const handleMcpGet405: RouteHandler = (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } res.addHeader("Allow", "POST, DELETE"); res.json({ error: "method not allowed" }, 405); }; /** * POST /__dev/mcp/message — legacy HTTP+SSE message sink. Delivers the JSON-RPC * response on the matching open SSE stream (202 here); with no open stream it * degrades to an inline Streamable HTTP response, so the path serves a plain * POST client too. */ const handleMcpLegacyMessage: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } try { const { getDefaultDevServer } = await import("./mcp.js"); const server = getDefaultDevServer(); const sessionId = (req.query?.sessionId as string) || (req.header("mcp-session-id") ?? ""); applyMcpOutcome(res, await server.dispatchSseMessage(mcpNormalizeBody(req.body), sessionId)); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; /** * GET /__dev/mcp/sse — legacy HTTP+SSE stream. Opens a persistent SSE connection: * first the `endpoint` event naming the (session-tagged) POST target, then each * JSON-RPC response as it arrives. Node's single event loop lets a separate POST * to /message feed this stream via an in-process channel. */ const handleMcpSse: RouteHandler = async (req, res) => { if (!mcpRequestAllowed(req)) { res.json({ error: "MCP forbidden" }, 404); return; } const { getDefaultDevServer } = await import("./mcp.js"); const server = getDefaultDevServer(); const sessionId = server.openSession(); const base = (req.path || "/__dev/mcp/sse").replace(/\/sse$/, ""); await res.stream(server.sseStream(sessionId, `${base}/message?sessionId=${sessionId}`)); }; const handleScaffoldList: RouteHandler = (_req, res) => { res.json({ scaffolds: [ { name: "route", description: "Create a new route file in src/routes/" }, { name: "model", description: "Create a new ORM model in src/models/" }, { name: "migration", description: "Create a new SQL migration file" }, { name: "middleware", description: "Create a new middleware class" }, ], }); }; const handleScaffoldRun: RouteHandler = async (req, res) => { const body = (req.body as Record) || {}; const kind = (body.kind as string) || ""; const name = (body.name as string) || ""; if (!kind || !name || !/^[A-Za-z_][\w]*$/.test(name)) { res.json({ error: "kind and valid name required" }, 400); return; } try { const { execFileSync } = await import("node:child_process"); const output = execFileSync("npx", ["tina4nodejs", "generate", kind, name], { cwd: resolve(process.cwd()), timeout: 30_000, encoding: "utf-8", }).toString(); res.json({ ok: true, kind, name, output }); } catch (e) { res.json({ error: (e as Error).message }, 500); } }; // ── Plan routes ───────────────────────────────────────────── const handlePlanCurrent: RouteHandler = async (_req, res) => { const { Plan } = await import("./plan.js"); res.json(Plan.current()); }; const handlePlanList: RouteHandler = async (_req, res) => { const { Plan } = await import("./plan.js"); res.json({ plans: Plan.listPlans() }); }; const handlePlanCreate: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json( Plan.create( (body.title as string) || "", (body.goal as string) || "", (body.steps as string[]) || [], body.make_current !== false, ), ); }; const handlePlanSwitch: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(Plan.setCurrent((body.name as string) || "")); }; const handlePlanCompleteStep: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(Plan.completeStep((body.index as number) ?? -1, (body.name as string) || "")); }; const handlePlanAddStep: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(Plan.addStep((body.text as string) || "", (body.name as string) || "")); }; const handlePlanNote: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(Plan.appendNote((body.text as string) || "", (body.name as string) || "")); }; const handlePlanArchive: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(Plan.archive((body.name as string) || "")); }; const handlePlanRead: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const url = new URL(req.url ?? "/", "http://localhost"); const name = url.searchParams.get("name") ?? ""; res.json(Plan.read(name)); }; const handlePlanFlesh: RouteHandler = async (req, res) => { const { Plan } = await import("./plan.js"); const body = (req.body as Record) || {}; res.json(await Plan.flesh((body.name as string) || "", (body.prompt as string) || "")); }; // ── Project index routes ──────────────────────────────────── const handleIndexRebuild: RouteHandler = async (_req, res) => { const { ProjectIndex } = await import("./projectIndex.js"); res.json(ProjectIndex.refresh()); }; const handleIndexSearch: RouteHandler = async (req, res) => { const { ProjectIndex } = await import("./projectIndex.js"); const url = new URL(req.url ?? "/", "http://localhost"); const q = url.searchParams.get("q") ?? ""; const limit = parseInt(url.searchParams.get("limit") ?? "20", 10); res.json({ results: ProjectIndex.search(q, limit) }); }; const handleIndexFile: RouteHandler = async (req, res) => { const { ProjectIndex } = await import("./projectIndex.js"); const url = new URL(req.url ?? "/", "http://localhost"); const p = url.searchParams.get("path") ?? ""; res.json(ProjectIndex.fileEntry(p)); }; const handleIndexOverview: RouteHandler = async (_req, res) => { const { ProjectIndex } = await import("./projectIndex.js"); res.json(ProjectIndex.overview()); }; // --------------------------------------------------------------------------- // Live API RAG (Docs) — plan/v3/22-LIVE-API-RAG.md // --------------------------------------------------------------------------- const handleDocsSearch: RouteHandler = async (req, res) => { const { Docs } = await import("./docs.js"); const url = new URL(req.url ?? "/", "http://localhost"); const q = url.searchParams.get("q") ?? ""; const k = parseInt(url.searchParams.get("k") ?? "5", 10); const source = url.searchParams.get("source") ?? "all"; const includePrivate = isTruthy(url.searchParams.get("include_private") ?? "false"); const start = Date.now(); const results = Docs.mcpSearch(q, k, undefined, source, includePrivate); res.json({ ok: true, query: q, results, took_ms: Date.now() - start }); }; const handleDocsClass: RouteHandler = async (req, res) => { const { Docs } = await import("./docs.js"); const url = new URL(req.url ?? "/", "http://localhost"); const name = url.searchParams.get("name") ?? ""; const spec = Docs.mcpClass(name); if (!spec) { res.json({ ok: false, error: `class not found: ${name}` }, 404); return; } res.json({ ok: true, class: spec }); }; const handleDocsMethod: RouteHandler = async (req, res) => { const { Docs } = await import("./docs.js"); const url = new URL(req.url ?? "/", "http://localhost"); const cls = url.searchParams.get("class") ?? ""; const name = url.searchParams.get("name") ?? ""; const spec = Docs.mcpMethod(cls, name); if (!spec) { res.json({ ok: false, error: `method not found: ${cls}.${name}` }, 404); return; } res.json({ ok: true, method: spec }); }; const handleDocsIndex: RouteHandler = async (req, res) => { const { Docs } = await import("./docs.js"); const url = new URL(req.url ?? "/", "http://localhost"); const source = url.searchParams.get("source") ?? "all"; const docs = new (Docs as any)(process.cwd()); let entries: any[] = docs.index(); if (source !== "all") entries = entries.filter((e) => e.source === source); res.json({ ok: true, count: entries.length, entries }); }; const handleDocsWellKnown: RouteHandler = async (_req, res) => { res.json({ ok: true, name: "tina4-live-docs", description: "Live API docs for this Tina4 project (framework + user code)", spec: "plan/v3/22-LIVE-API-RAG.md", endpoints: { search: "/__dev/api/docs/search?q=&k=&source=&include_private=", class: "/__dev/api/docs/class?name=", method: "/__dev/api/docs/method?class=&name=", index: "/__dev/api/docs/index?source=", }, mcp_tools: ["api_search", "api_class", "api_method"], }); }; // --------------------------------------------------------------------------- // Dev Admin JS handler — serves the shared JS file // --------------------------------------------------------------------------- const handleDevAdminJs: RouteHandler = async (_req, res) => { const { readFileSync, existsSync } = await import("node:fs"); const { dirname, join, resolve } = await import("node:path"); const { fileURLToPath } = await import("node:url"); const dir = dirname(fileURLToPath(import.meta.url)); // Try multiple paths — handles both monorepo dev and npm-installed package const candidates = [ join(dir, "..", "public", "js", "tina4-dev-admin.min.js"), // src/../public/js/ join(dir, "..", "..", "public", "js", "tina4-dev-admin.min.js"), // deeper nesting resolve(process.cwd(), "node_modules", "tina4-nodejs", "packages", "core", "public", "js", "tina4-dev-admin.min.js"), resolve(process.cwd(), "public", "js", "tina4-dev-admin.min.js"), // project public/ ]; for (const jsPath of candidates) { if (existsSync(jsPath)) { try { const content = readFileSync(jsPath, "utf-8"); res.raw.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" }); res.raw.end(content); return; } catch { /* try next */ } } } // File not found — no legacy fallback res.raw.writeHead(404, { "Content-Type": "text/plain" }); res.raw.end("tina4-dev-admin.min.js not found"); }; // --------------------------------------------------------------------------- // Overlay script — floating Tina4 button // --------------------------------------------------------------------------- function renderToolbarHtml(ctx: { version: string; method: string; path: string; matchedPattern: string; requestId: string; routeCount: number; reload?: boolean; }): string { const nodeVersion = process.version; // DEVADMIN-DEC-04: the toolbar is injected into every text/html response // (including 404s), so the reflected request path/method MUST be HTML-escaped // or a crafted path reflects `; } /** * CSS for the injected dev toolbar. Served as an external stylesheet (see * register()) so the toolbar carries no inline `style=` and stays CSP-clean * under a strict `default-src 'self'`. Mirrors PHP DevAdmin::toolbarCss(). */ function toolbarCss(): string { return `#tina4-dev-toolbar{position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px} #tina4-dev-toolbar a{text-decoration:none} #tina4-ver-btn{color:#2e7d32;font-weight:bold;cursor:pointer;text-decoration:underline dotted} #tina4-ver-modal{display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #2e7d32;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,.5);font-family:monospace;font-size:13px;color:#cdd6f4} .t4-modal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px} .t4-modal-title{color:#89b4fa} #tina4-ver-body{line-height:1.8} .t4-x{cursor:pointer;color:#888} .t4-bar-close{margin-left:8px} .t4-green{color:#4caf50} .t4-dim{color:#888} .t4-arrow{color:#666} .t4-yellow{color:#ffeb3b} .t4-blue{color:#90caf9} .t4-ok{color:#a6e3a1} .t4-warn{color:#f9e2af} .t4-err{color:#f38ba8} .t4-purple{color:#cba6f7} .t4-link{color:#89b4fa} .t4-code{background:#313244;padding:2px 6px;border-radius:3px} .t4-note{margin-top:6px} .t4-dash{color:#ef9a9a;margin-left:auto;cursor:pointer} #tina4-dev-panel{position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all .2s} #tina4-dev-panel iframe{width:100%;height:100%;border:1px solid #2e7d32;border-radius:.5rem;box-shadow:0 8px 32px rgba(0,0,0,.5);background:#0f172a}`; } /** * JS for the injected dev toolbar — the version-check modal, the dashboard * overlay, and the WebSocket-primary live reloader. Served as an external * script so the toolbar carries no inline handlers or `