Skip to content

Commit 5b6e8c7

Browse files
authored
feat: FORCE_DIRECT_DB — force on-prem NLQ execution, block syntex (#126) (#133)
feat: FORCE_DIRECT_DB — force on-prem NLQ execution, block syntex Closes #126
1 parent b67f175 commit 5b6e8c7

9 files changed

Lines changed: 159 additions & 14 deletions

File tree

codebenders-dashboard/app/query/page.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { QueryPlanPanel } from "@/components/query-plan-panel"
1010
import { QueryHistoryPanel } from "@/components/query-history-panel"
1111
import { analyzePrompt } from "@/lib/prompt-analyzer"
1212
import { executeQuery } from "@/lib/query-executor"
13+
import { isForceDirectDb } from "@/lib/config"
1314
import type { QueryPlan, QueryResult, HistoryEntry } from "@/lib/types"
1415
import { Loader2, Sparkles, PanelLeft } from "lucide-react"
1516

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

24+
/** Build-time / env snapshot: when true, UI locks to direct DB (matches `lib/config` `isForceDirectDb`). */
25+
const directDbForcedByEnv = isForceDirectDb()
26+
2327
export default function QueryPage() {
2428
const [institution, setInstitution] = useState<string>(INSTITUTIONS[0].code)
2529
const [prompt, setPrompt] = useState<string>("")
@@ -154,6 +158,15 @@ export default function QueryPage() {
154158
}
155159
}
156160

161+
let directDbModeHint: string
162+
if (directDbForcedByEnv) {
163+
directDbModeHint = "(FORCE_DIRECT_DB — external API disabled)"
164+
} else if (useDirectDB) {
165+
directDbModeHint = "(execute SQL directly)"
166+
} else {
167+
directDbModeHint = "(fetch from API endpoints)"
168+
}
169+
157170
return (
158171
<div className="min-h-screen bg-background flex flex-col">
159172
{/* Slim page-level header bar */}
@@ -202,14 +215,19 @@ export default function QueryPage() {
202215
{/* Query controls */}
203216
<div className="border border-border/60 rounded-lg p-5 space-y-4">
204217
{/* DB mode toggle row */}
205-
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
206-
<Switch id="db-mode" checked={useDirectDB} onCheckedChange={setUseDirectDB} />
218+
<div className="flex flex-wrap items-center gap-3 pb-4 border-b border-border/40">
219+
<Switch
220+
id="db-mode"
221+
checked={directDbForcedByEnv || useDirectDB}
222+
onCheckedChange={(v) => {
223+
if (!directDbForcedByEnv) setUseDirectDB(v)
224+
}}
225+
disabled={directDbForcedByEnv}
226+
/>
207227
<Label htmlFor="db-mode" className="text-sm font-medium cursor-pointer">
208-
{useDirectDB ? "Direct Database" : "API Mode"}
228+
{directDbForcedByEnv || useDirectDB ? "Direct Database" : "API Mode"}
209229
</Label>
210-
<span className="text-xs text-muted-foreground font-mono">
211-
{useDirectDB ? "(execute SQL directly)" : "(fetch from API endpoints)"}
212-
</span>
230+
<span className="text-xs text-muted-foreground font-mono">{directDbModeHint}</span>
213231
</div>
214232

215233
{/* Institution selector */}

codebenders-dashboard/content/ai-transparency.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,11 +332,11 @@ export const AI_SURFACES: AISurface[] = [
332332
trainingData: null,
333333
runsOn: "schools.syntex-ai.com — hosted by the project team, not by the institution.",
334334
dataFlow:
335-
"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.",
335+
"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.",
336336
retentionPolicy:
337337
"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.",
338338
notes:
339-
"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.",
339+
"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.",
340340
},
341341

342342
// ─────────────────────────── In-development ───────────────────────────

codebenders-dashboard/env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ DB_SSL=false
1111
# OpenAI Configuration (for AI-powered query generation)
1212
OPENAI_API_KEY=your-openai-api-key-here
1313

14+
# Query execution hardening (default: false / unset)
15+
# When "true", all NLQ results run via /api/execute-sql only; fetches to
16+
# schools.syntex-ai.com are blocked. Logged at server start as
17+
# [transparency] FORCE_DIRECT_DB=true; external data flows disabled
18+
# Exposed to the browser via next.config env — set before `next dev` / `next build`.
19+
FORCE_DIRECT_DB=false
20+
1421
# Supabase Auth (required for RBAC)
1522
# Find these in Supabase → Project Settings → API
1623
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export async function register() {
2+
if (process.env.NEXT_RUNTIME === "nodejs") {
3+
const { logForceDirectDbStartupProbe } = await import("./lib/config")
4+
logForceDirectDbStartupProbe()
5+
}
6+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
2+
import {
3+
assertExternalDataApiAllowed,
4+
buildExternalAnalysisReadyUrl,
5+
isForceDirectDb,
6+
} from "../config"
7+
8+
describe("FORCE_DIRECT_DB config", () => {
9+
const prev = process.env.FORCE_DIRECT_DB
10+
11+
beforeEach(() => {
12+
delete process.env.FORCE_DIRECT_DB
13+
})
14+
15+
afterEach(() => {
16+
if (prev === undefined) delete process.env.FORCE_DIRECT_DB
17+
else process.env.FORCE_DIRECT_DB = prev
18+
})
19+
20+
it("isForceDirectDb is false when unset", () => {
21+
expect(isForceDirectDb()).toBe(false)
22+
})
23+
24+
it("isForceDirectDb is true only for exact \"true\"", () => {
25+
process.env.FORCE_DIRECT_DB = "true"
26+
expect(isForceDirectDb()).toBe(true)
27+
process.env.FORCE_DIRECT_DB = "1"
28+
expect(isForceDirectDb()).toBe(false)
29+
})
30+
31+
it("assertExternalDataApiAllowed allows syntex URL when not forced", () => {
32+
process.env.FORCE_DIRECT_DB = "false"
33+
expect(() =>
34+
assertExternalDataApiAllowed("https://schools.syntex-ai.com/bscc/analysis-ready?limit=1"),
35+
).not.toThrow()
36+
})
37+
38+
it("assertExternalDataApiAllowed throws when forced and URL is syntex", () => {
39+
process.env.FORCE_DIRECT_DB = "true"
40+
expect(() =>
41+
assertExternalDataApiAllowed("https://schools.syntex-ai.com/bscc/analysis-ready?limit=1"),
42+
).toThrow("FORCE_DIRECT_DB is set; external data API blocked")
43+
})
44+
45+
it("assertExternalDataApiAllowed allows http scheme", () => {
46+
process.env.FORCE_DIRECT_DB = "true"
47+
expect(() =>
48+
assertExternalDataApiAllowed("http://schools.syntex-ai.com/bscc/analysis-ready"),
49+
).toThrow()
50+
})
51+
52+
it("assertExternalDataApiAllowed no-ops on empty url", () => {
53+
process.env.FORCE_DIRECT_DB = "true"
54+
expect(() => assertExternalDataApiAllowed("")).not.toThrow()
55+
})
56+
57+
it("buildExternalAnalysisReadyUrl is empty when forced", () => {
58+
process.env.FORCE_DIRECT_DB = "true"
59+
const params = new URLSearchParams({ limit: "10" })
60+
expect(buildExternalAnalysisReadyUrl("bscc", params)).toBe("")
61+
})
62+
63+
it("buildExternalAnalysisReadyUrl matches schools.syntex-ai.com analysis-ready shape when not forced", () => {
64+
process.env.FORCE_DIRECT_DB = "false"
65+
const params = new URLSearchParams({ limit: "1000", offset: "0" })
66+
expect(buildExternalAnalysisReadyUrl("bscc", params)).toBe(
67+
"https://schools.syntex-ai.com/bscc/analysis-ready?limit=1000&offset=0",
68+
)
69+
})
70+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Deployment flags. `FORCE_DIRECT_DB` is inlined for client bundles via `next.config.ts` `env`.
3+
* Default when unset: not "true" → hardened mode off (preserves legacy external API path).
4+
*/
5+
6+
const SYNTEX_DATA_API = /https?:\/\/schools\.syntex-ai\.com\//i
7+
8+
export function isForceDirectDb(): boolean {
9+
return process.env.FORCE_DIRECT_DB === "true"
10+
}
11+
12+
/**
13+
* Full analysis-ready URL for the external host, or "" when `FORCE_DIRECT_DB` blocks external flows.
14+
*/
15+
export function buildExternalAnalysisReadyUrl(institutionCode: string, queryParams: URLSearchParams): string {
16+
if (isForceDirectDb()) return ""
17+
return `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}`
18+
}
19+
20+
/**
21+
* Fail-closed guard: call before any fetch to a non-institutional analysis-ready host.
22+
*/
23+
export function assertExternalDataApiAllowed(url: string): void {
24+
if (!url) return
25+
if (isForceDirectDb() && SYNTEX_DATA_API.test(url)) {
26+
throw new Error("FORCE_DIRECT_DB is set; external data API blocked")
27+
}
28+
}
29+
30+
let probeLogged = false
31+
32+
/** Server startup: log once when hardening is active (see instrumentation.ts). */
33+
export function logForceDirectDbStartupProbe(): void {
34+
if (probeLogged) return
35+
probeLogged = true
36+
if (isForceDirectDb()) {
37+
console.log("[transparency] FORCE_DIRECT_DB=true; external data flows disabled")
38+
}
39+
}

codebenders-dashboard/lib/prompt-analyzer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { QueryPlan } from "./types"
2+
import { buildExternalAnalysisReadyUrl } from "./config"
23

34
// Database schema mapping
45
const SCHEMA_CONFIG = {
@@ -154,7 +155,7 @@ ORDER BY ${orderByColumn}`.trim()
154155
})
155156
}
156157

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

159160
return {
160161
metric,

codebenders-dashboard/lib/query-executor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import type { QueryPlan, QueryResult } from "./types"
2+
import { assertExternalDataApiAllowed, isForceDirectDb } from "./config"
23

34
export async function executeQuery(
45
plan: QueryPlan,
56
institutionCode: string,
67
useDirectDB = false,
78
): Promise<QueryResult> {
89
try {
9-
if (useDirectDB) {
10+
if (isForceDirectDb() || useDirectDB) {
1011
return await executeDirectDB(plan, institutionCode)
1112
}
1213

1314
const url = plan.queryString
15+
assertExternalDataApiAllowed(url)
1416
const response = await fetch(url)
1517
if (!response.ok) {
1618
throw new Error(`API error: ${response.status}`)
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import type { NextConfig } from "next";
1+
import type { NextConfig } from "next"
22

33
const nextConfig: NextConfig = {
4-
/* config options here */
5-
};
4+
env: {
5+
FORCE_DIRECT_DB: process.env.FORCE_DIRECT_DB ?? "false",
6+
},
7+
}
68

7-
export default nextConfig;
9+
export default nextConfig

0 commit comments

Comments
 (0)