From 987f7f704fbee05e84788de07819f2f3aa9b054a Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 11:31:11 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20FORCE=5FDIRECT=5FDB=20hardening=20?= =?UTF-8?q?=E2=80=94=20block=20syntex-ai.com=20data=20path=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/config: isForceDirectDb, assertExternalDataApiAllowed, startup probe log - next.config env inlines FORCE_DIRECT_DB for client bundles - instrumentation.ts logs [transparency] line on server start when enabled - query-executor: force direct path when env set; guard before external fetch - prompt-analyzer: omit syntex queryString when forced - query page: disable API mode toggle when forced - env.example + ai-transparency syntex entry document flag - vitest: force-direct-db config tests Co-authored-by: Cursor --- codebenders-dashboard/app/query/page.tsx | 22 ++++++-- .../content/ai-transparency.ts | 4 +- codebenders-dashboard/env.example | 7 +++ codebenders-dashboard/instrumentation.ts | 6 +++ .../lib/__tests__/force-direct-db.test.ts | 52 +++++++++++++++++++ codebenders-dashboard/lib/config.ts | 31 +++++++++++ codebenders-dashboard/lib/prompt-analyzer.ts | 5 +- codebenders-dashboard/lib/query-executor.ts | 5 +- codebenders-dashboard/next.config.ts | 10 ++-- 9 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 codebenders-dashboard/instrumentation.ts create mode 100644 codebenders-dashboard/lib/__tests__/force-direct-db.test.ts create mode 100644 codebenders-dashboard/lib/config.ts diff --git a/codebenders-dashboard/app/query/page.tsx b/codebenders-dashboard/app/query/page.tsx index a0ab4fe..ebdab8d 100644 --- a/codebenders-dashboard/app/query/page.tsx +++ b/codebenders-dashboard/app/query/page.tsx @@ -10,6 +10,7 @@ import { QueryPlanPanel } from "@/components/query-plan-panel" import { QueryHistoryPanel } from "@/components/query-history-panel" import { analyzePrompt } from "@/lib/prompt-analyzer" import { executeQuery } from "@/lib/query-executor" +import { isForceDirectDb } from "@/lib/config" import type { QueryPlan, QueryResult, HistoryEntry } from "@/lib/types" import { Loader2, Sparkles, PanelLeft } from "lucide-react" @@ -20,6 +21,8 @@ const INSTITUTIONS = [ { name: "Thomas More University", code: "ky" }, ] +const forceDirectDbEnv = isForceDirectDb() + export default function QueryPage() { const [institution, setInstitution] = useState(INSTITUTIONS[0].code) const [prompt, setPrompt] = useState("") @@ -202,13 +205,24 @@ export default function QueryPage() { {/* Query controls */}
{/* DB mode toggle row */} -
- +
+ { + if (!forceDirectDbEnv) setUseDirectDB(v) + }} + disabled={forceDirectDbEnv} + /> - {useDirectDB ? "(execute SQL directly)" : "(fetch from API endpoints)"} + {forceDirectDbEnv + ? "(FORCE_DIRECT_DB — external API disabled)" + : useDirectDB + ? "(execute SQL directly)" + : "(fetch from API endpoints)"}
diff --git a/codebenders-dashboard/content/ai-transparency.ts b/codebenders-dashboard/content/ai-transparency.ts index cb83f31..820f6b5 100644 --- a/codebenders-dashboard/content/ai-transparency.ts +++ b/codebenders-dashboard/content/ai-transparency.ts @@ -332,11 +332,11 @@ export const AI_SURFACES: AISurface[] = [ trainingData: null, runsOn: "schools.syntex-ai.com — hosted by the project team, not by the institution.", dataFlow: - "When the dashboard is run in `useDirectDB = false` mode (default for some query paths in `lib/query-executor.ts`), query plans are converted into URL parameters and fetched from `https://schools.syntex-ai.com//analysis-ready`. The API returns analysis-ready rows that the dashboard then groups/aggregates client-side.\n\nWhen `useDirectDB = true`, queries go to the local `/api/execute-sql` route instead and never reach syntex-ai.com.", + "When the dashboard is run in `useDirectDB = false` mode (default for some query paths in `lib/query-executor.ts`), query plans are converted into URL parameters and fetched from `https://schools.syntex-ai.com//analysis-ready`. The API returns analysis-ready rows that the dashboard then groups/aggregates client-side.\n\nWhen `useDirectDB = true`, queries go to the local `/api/execute-sql` route instead and never reach syntex-ai.com.\n\nWhen the deployment sets `FORCE_DIRECT_DB=true` (see `lib/config.ts`, `env.example`, #126), the external URL is never built or fetched; execution is always direct-DB with a fail-closed guard.", retentionPolicy: "Logging and retention at schools.syntex-ai.com are governed by the project deployment, not by the institution. Institutions evaluating procurement should ask whether their deployment uses direct-DB mode or the external API.", notes: - "This entry exists for full-stack transparency: institutions deploying this dashboard should know that, in default configuration for some queries, student-level rows are returned from a non-institutional host. A deployment hardening option to force `useDirectDB = true` end-to-end is tracked as a follow-up issue.", + "Institutions should confirm whether their deployment uses the external host or direct DB. Procurement-hardened installs set `FORCE_DIRECT_DB=true` (#126) so student-level rows are never fetched from schools.syntex-ai.com.", }, // ─────────────────────────── In-development ─────────────────────────── diff --git a/codebenders-dashboard/env.example b/codebenders-dashboard/env.example index 404d66a..7e43f67 100644 --- a/codebenders-dashboard/env.example +++ b/codebenders-dashboard/env.example @@ -11,6 +11,13 @@ DB_SSL=false # OpenAI Configuration (for AI-powered query generation) OPENAI_API_KEY=your-openai-api-key-here +# Query execution hardening (default: false / unset) +# When "true", all NLQ results run via /api/execute-sql only; fetches to +# schools.syntex-ai.com are blocked. Logged at server start as +# [transparency] FORCE_DIRECT_DB=true; external data flows disabled +# Exposed to the browser via next.config env — set before `next dev` / `next build`. +FORCE_DIRECT_DB=false + # Supabase Auth (required for RBAC) # Find these in Supabase → Project Settings → API NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co diff --git a/codebenders-dashboard/instrumentation.ts b/codebenders-dashboard/instrumentation.ts new file mode 100644 index 0000000..859ccfa --- /dev/null +++ b/codebenders-dashboard/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { logForceDirectDbStartupProbe } = await import("./lib/config") + logForceDirectDbStartupProbe() + } +} diff --git a/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts b/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts new file mode 100644 index 0000000..50422cd --- /dev/null +++ b/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { assertExternalDataApiAllowed, isForceDirectDb } from "../config" + +describe("FORCE_DIRECT_DB config", () => { + const prev = process.env.FORCE_DIRECT_DB + + beforeEach(() => { + delete process.env.FORCE_DIRECT_DB + }) + + afterEach(() => { + if (prev === undefined) delete process.env.FORCE_DIRECT_DB + else process.env.FORCE_DIRECT_DB = prev + }) + + it("isForceDirectDb is false when unset", () => { + expect(isForceDirectDb()).toBe(false) + }) + + it("isForceDirectDb is true only for exact \"true\"", () => { + process.env.FORCE_DIRECT_DB = "true" + expect(isForceDirectDb()).toBe(true) + process.env.FORCE_DIRECT_DB = "1" + expect(isForceDirectDb()).toBe(false) + }) + + it("assertExternalDataApiAllowed allows syntex URL when not forced", () => { + process.env.FORCE_DIRECT_DB = "false" + expect(() => + assertExternalDataApiAllowed("https://schools.syntex-ai.com/bscc/analysis-ready?limit=1"), + ).not.toThrow() + }) + + it("assertExternalDataApiAllowed throws when forced and URL is syntex", () => { + process.env.FORCE_DIRECT_DB = "true" + expect(() => + assertExternalDataApiAllowed("https://schools.syntex-ai.com/bscc/analysis-ready?limit=1"), + ).toThrow("FORCE_DIRECT_DB is set; external data API blocked") + }) + + it("assertExternalDataApiAllowed allows http scheme", () => { + process.env.FORCE_DIRECT_DB = "true" + expect(() => + assertExternalDataApiAllowed("http://schools.syntex-ai.com/bscc/analysis-ready"), + ).toThrow() + }) + + it("assertExternalDataApiAllowed no-ops on empty url", () => { + process.env.FORCE_DIRECT_DB = "true" + expect(() => assertExternalDataApiAllowed("")).not.toThrow() + }) +}) diff --git a/codebenders-dashboard/lib/config.ts b/codebenders-dashboard/lib/config.ts new file mode 100644 index 0000000..b6811d5 --- /dev/null +++ b/codebenders-dashboard/lib/config.ts @@ -0,0 +1,31 @@ +/** + * Deployment flags. `FORCE_DIRECT_DB` is inlined for client bundles via `next.config.ts` `env`. + * Default when unset: not "true" → hardened mode off (preserves legacy external API path). + */ + +const SYNTEX_DATA_API = /https?:\/\/schools\.syntex-ai\.com\//i + +export function isForceDirectDb(): boolean { + return process.env.FORCE_DIRECT_DB === "true" +} + +/** + * Fail-closed guard: call before any fetch to a non-institutional analysis-ready host. + */ +export function assertExternalDataApiAllowed(url: string): void { + if (!url) return + if (isForceDirectDb() && SYNTEX_DATA_API.test(url)) { + throw new Error("FORCE_DIRECT_DB is set; external data API blocked") + } +} + +let probeLogged = false + +/** Server startup: log once when hardening is active (see instrumentation.ts). */ +export function logForceDirectDbStartupProbe(): void { + if (probeLogged) return + probeLogged = true + if (isForceDirectDb()) { + console.log("[transparency] FORCE_DIRECT_DB=true; external data flows disabled") + } +} diff --git a/codebenders-dashboard/lib/prompt-analyzer.ts b/codebenders-dashboard/lib/prompt-analyzer.ts index f07bb95..a0305c4 100644 --- a/codebenders-dashboard/lib/prompt-analyzer.ts +++ b/codebenders-dashboard/lib/prompt-analyzer.ts @@ -1,4 +1,5 @@ import type { QueryPlan } from "./types" +import { isForceDirectDb } from "./config" // Database schema mapping const SCHEMA_CONFIG = { @@ -154,7 +155,9 @@ ORDER BY ${orderByColumn}`.trim() }) } - const queryString = `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}` + const queryString = isForceDirectDb() + ? "" + : `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}` return { metric, diff --git a/codebenders-dashboard/lib/query-executor.ts b/codebenders-dashboard/lib/query-executor.ts index edbdb9d..c2cd656 100644 --- a/codebenders-dashboard/lib/query-executor.ts +++ b/codebenders-dashboard/lib/query-executor.ts @@ -1,4 +1,5 @@ import type { QueryPlan, QueryResult } from "./types" +import { assertExternalDataApiAllowed, isForceDirectDb } from "./config" export async function executeQuery( plan: QueryPlan, @@ -6,11 +7,13 @@ export async function executeQuery( useDirectDB = false, ): Promise { try { - if (useDirectDB) { + const forceDirect = isForceDirectDb() + if (forceDirect || useDirectDB) { return await executeDirectDB(plan, institutionCode) } const url = plan.queryString + assertExternalDataApiAllowed(url) const response = await fetch(url) if (!response.ok) { throw new Error(`API error: ${response.status}`) diff --git a/codebenders-dashboard/next.config.ts b/codebenders-dashboard/next.config.ts index e9ffa30..43c3295 100644 --- a/codebenders-dashboard/next.config.ts +++ b/codebenders-dashboard/next.config.ts @@ -1,7 +1,9 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from "next" const nextConfig: NextConfig = { - /* config options here */ -}; + env: { + FORCE_DIRECT_DB: process.env.FORCE_DIRECT_DB ?? "false", + }, +} -export default nextConfig; +export default nextConfig From ee4be18a49c9624d9c7ca4b3b85d86e26398daec Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 11:42:39 -0400 Subject: [PATCH 2/2] refactor: simplify FORCE_DIRECT_DB wiring (code-simplifier) - buildExternalAnalysisReadyUrl in config; prompt-analyzer uses it - query-executor: isForceDirectDb() || useDirectDB branch - query page: directDbForcedByEnv + clearer mode hint copy - tests for URL builder Co-authored-by: Cursor --- codebenders-dashboard/app/query/page.tsx | 28 +++++++++++-------- .../lib/__tests__/force-direct-db.test.ts | 20 ++++++++++++- codebenders-dashboard/lib/config.ts | 8 ++++++ codebenders-dashboard/lib/prompt-analyzer.ts | 6 ++-- codebenders-dashboard/lib/query-executor.ts | 3 +- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/codebenders-dashboard/app/query/page.tsx b/codebenders-dashboard/app/query/page.tsx index ebdab8d..0135d56 100644 --- a/codebenders-dashboard/app/query/page.tsx +++ b/codebenders-dashboard/app/query/page.tsx @@ -21,7 +21,8 @@ const INSTITUTIONS = [ { name: "Thomas More University", code: "ky" }, ] -const forceDirectDbEnv = isForceDirectDb() +/** Build-time / env snapshot: when true, UI locks to direct DB (matches `lib/config` `isForceDirectDb`). */ +const directDbForcedByEnv = isForceDirectDb() export default function QueryPage() { const [institution, setInstitution] = useState(INSTITUTIONS[0].code) @@ -157,6 +158,15 @@ export default function QueryPage() { } } + let directDbModeHint: string + if (directDbForcedByEnv) { + directDbModeHint = "(FORCE_DIRECT_DB — external API disabled)" + } else if (useDirectDB) { + directDbModeHint = "(execute SQL directly)" + } else { + directDbModeHint = "(fetch from API endpoints)" + } + return (
{/* Slim page-level header bar */} @@ -208,22 +218,16 @@ export default function QueryPage() {
{ - if (!forceDirectDbEnv) setUseDirectDB(v) + if (!directDbForcedByEnv) setUseDirectDB(v) }} - disabled={forceDirectDbEnv} + disabled={directDbForcedByEnv} /> - - {forceDirectDbEnv - ? "(FORCE_DIRECT_DB — external API disabled)" - : useDirectDB - ? "(execute SQL directly)" - : "(fetch from API endpoints)"} - + {directDbModeHint}
{/* Institution selector */} diff --git a/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts b/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts index 50422cd..01c03bf 100644 --- a/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts +++ b/codebenders-dashboard/lib/__tests__/force-direct-db.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" -import { assertExternalDataApiAllowed, isForceDirectDb } from "../config" +import { + assertExternalDataApiAllowed, + buildExternalAnalysisReadyUrl, + isForceDirectDb, +} from "../config" describe("FORCE_DIRECT_DB config", () => { const prev = process.env.FORCE_DIRECT_DB @@ -49,4 +53,18 @@ describe("FORCE_DIRECT_DB config", () => { process.env.FORCE_DIRECT_DB = "true" expect(() => assertExternalDataApiAllowed("")).not.toThrow() }) + + it("buildExternalAnalysisReadyUrl is empty when forced", () => { + process.env.FORCE_DIRECT_DB = "true" + const params = new URLSearchParams({ limit: "10" }) + expect(buildExternalAnalysisReadyUrl("bscc", params)).toBe("") + }) + + it("buildExternalAnalysisReadyUrl matches schools.syntex-ai.com analysis-ready shape when not forced", () => { + process.env.FORCE_DIRECT_DB = "false" + const params = new URLSearchParams({ limit: "1000", offset: "0" }) + expect(buildExternalAnalysisReadyUrl("bscc", params)).toBe( + "https://schools.syntex-ai.com/bscc/analysis-ready?limit=1000&offset=0", + ) + }) }) diff --git a/codebenders-dashboard/lib/config.ts b/codebenders-dashboard/lib/config.ts index b6811d5..e82cc6c 100644 --- a/codebenders-dashboard/lib/config.ts +++ b/codebenders-dashboard/lib/config.ts @@ -9,6 +9,14 @@ export function isForceDirectDb(): boolean { return process.env.FORCE_DIRECT_DB === "true" } +/** + * Full analysis-ready URL for the external host, or "" when `FORCE_DIRECT_DB` blocks external flows. + */ +export function buildExternalAnalysisReadyUrl(institutionCode: string, queryParams: URLSearchParams): string { + if (isForceDirectDb()) return "" + return `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}` +} + /** * Fail-closed guard: call before any fetch to a non-institutional analysis-ready host. */ diff --git a/codebenders-dashboard/lib/prompt-analyzer.ts b/codebenders-dashboard/lib/prompt-analyzer.ts index a0305c4..b4beac1 100644 --- a/codebenders-dashboard/lib/prompt-analyzer.ts +++ b/codebenders-dashboard/lib/prompt-analyzer.ts @@ -1,5 +1,5 @@ import type { QueryPlan } from "./types" -import { isForceDirectDb } from "./config" +import { buildExternalAnalysisReadyUrl } from "./config" // Database schema mapping const SCHEMA_CONFIG = { @@ -155,9 +155,7 @@ ORDER BY ${orderByColumn}`.trim() }) } - const queryString = isForceDirectDb() - ? "" - : `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}` + const queryString = buildExternalAnalysisReadyUrl(institutionCode, queryParams) return { metric, diff --git a/codebenders-dashboard/lib/query-executor.ts b/codebenders-dashboard/lib/query-executor.ts index c2cd656..3d86bdb 100644 --- a/codebenders-dashboard/lib/query-executor.ts +++ b/codebenders-dashboard/lib/query-executor.ts @@ -7,8 +7,7 @@ export async function executeQuery( useDirectDB = false, ): Promise { try { - const forceDirect = isForceDirectDb() - if (forceDirect || useDirectDB) { + if (isForceDirectDb() || useDirectDB) { return await executeDirectDB(plan, institutionCode) }