Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions codebenders-dashboard/app/query/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -20,6 +21,9 @@ const INSTITUTIONS = [
{ name: "Thomas More University", code: "ky" },
]

/** 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<string>(INSTITUTIONS[0].code)
const [prompt, setPrompt] = useState<string>("")
Expand Down Expand Up @@ -154,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 (
<div className="min-h-screen bg-background flex flex-col">
{/* Slim page-level header bar */}
Expand Down Expand Up @@ -202,14 +215,19 @@ export default function QueryPage() {
{/* Query controls */}
<div className="border border-border/60 rounded-lg p-5 space-y-4">
{/* DB mode toggle row */}
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<Switch id="db-mode" checked={useDirectDB} onCheckedChange={setUseDirectDB} />
<div className="flex flex-wrap items-center gap-3 pb-4 border-b border-border/40">
<Switch
id="db-mode"
checked={directDbForcedByEnv || useDirectDB}
onCheckedChange={(v) => {
if (!directDbForcedByEnv) setUseDirectDB(v)
}}
disabled={directDbForcedByEnv}
/>
<Label htmlFor="db-mode" className="text-sm font-medium cursor-pointer">
{useDirectDB ? "Direct Database" : "API Mode"}
{directDbForcedByEnv || useDirectDB ? "Direct Database" : "API Mode"}
</Label>
<span className="text-xs text-muted-foreground font-mono">
{useDirectDB ? "(execute SQL directly)" : "(fetch from API endpoints)"}
</span>
<span className="text-xs text-muted-foreground font-mono">{directDbModeHint}</span>
</div>

{/* Institution selector */}
Expand Down
4 changes: 2 additions & 2 deletions codebenders-dashboard/content/ai-transparency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<institution>/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/<institution>/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 ───────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions codebenders-dashboard/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<project-ref>.supabase.co
Expand Down
6 changes: 6 additions & 0 deletions codebenders-dashboard/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { logForceDirectDbStartupProbe } = await import("./lib/config")
logForceDirectDbStartupProbe()
}
}
70 changes: 70 additions & 0 deletions codebenders-dashboard/lib/__tests__/force-direct-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import {
assertExternalDataApiAllowed,
buildExternalAnalysisReadyUrl,
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()
})

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",
)
})
})
39 changes: 39 additions & 0 deletions codebenders-dashboard/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* 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"
}

/**
* 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.
*/
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")
}
}
3 changes: 2 additions & 1 deletion codebenders-dashboard/lib/prompt-analyzer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { QueryPlan } from "./types"
import { buildExternalAnalysisReadyUrl } from "./config"

// Database schema mapping
const SCHEMA_CONFIG = {
Expand Down Expand Up @@ -154,7 +155,7 @@ ORDER BY ${orderByColumn}`.trim()
})
}

const queryString = `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}`
const queryString = buildExternalAnalysisReadyUrl(institutionCode, queryParams)

return {
metric,
Expand Down
4 changes: 3 additions & 1 deletion codebenders-dashboard/lib/query-executor.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { QueryPlan, QueryResult } from "./types"
import { assertExternalDataApiAllowed, isForceDirectDb } from "./config"

export async function executeQuery(
plan: QueryPlan,
institutionCode: string,
useDirectDB = false,
): Promise<QueryResult> {
try {
if (useDirectDB) {
if (isForceDirectDb() || 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}`)
Expand Down
10 changes: 6 additions & 4 deletions codebenders-dashboard/next.config.ts
Original file line number Diff line number Diff line change
@@ -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
Loading