Skip to content

Commit fecc273

Browse files
Enhnace Code Archiecture
1 parent c407e5a commit fecc273

File tree

8 files changed

+256
-9
lines changed

8 files changed

+256
-9
lines changed

.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# OmanX example environment variables
2+
# Copy to .env and fill values before running in production
3+
4+
# OpenAI API (optional for degraded mode)
5+
OPENAI_API_KEY=
6+
OPENAI_MODEL=gpt-4o-mini
7+
8+
# Node environment
9+
NODE_ENV=development
10+
11+
# Rate limiting: max requests per 15 minutes
12+
RATE_LIMIT_MAX=120
13+
14+
# Admin key for admin endpoints in production
15+
ADMIN_KEY=
16+
17+
# Comma-separated allowed origins for CORS (leave blank for open during dev)
18+
ALLOWED_ORIGINS=
19+
20+
# Knowledge reload interval (ms)
21+
KNOWLEDGE_RELOAD_MS=30000
22+
23+
# Cache settings
24+
CACHE_TTL_MS=600000
25+
CACHE_MAX_ENTRIES=500
26+
27+
# Optional logging level: debug, info, warn, error
28+
LOG_LEVEL=debug

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
branches: [ main, master ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [18.x]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- name: Use Node.js ${{ matrix.node-version }}
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version: ${{ matrix.node-version }}
21+
- name: Install dependencies
22+
run: npm ci
23+
- name: Syntax checks
24+
run: |
25+
node --check server.js || true
26+
node --check router.js || true
27+
node --check localResponder.js || true
28+
node --check app.js || true
29+
- name: Run package tests
30+
run: npm test
31+
- name: Lint (optional)
32+
run: |
33+
if [ -f package.json ]; then
34+
if npm run | grep -q lint; then
35+
npm run lint || true
36+
fi
37+
fi

.gitignore

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
1-
node_modules
2-
.env
1+
node_modules/
2+
.env
3+
.env.local
4+
5+
# OS
6+
.DS_Store
7+
8+
# Logs
9+
npm-debug.log*
10+
yarn-debug.log*
11+
yarn-error.log*

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ RATE_LIMIT_MAX=120
3030
ADMIN_KEY=optional_admin_key
3131
```
3232

33+
You can use the provided example environment file as a starting point:
34+
35+
```bash
36+
cp .env.example .env
37+
# then edit .env to add your OPENAI_API_KEY and other values
38+
```
39+
3340
## Deployment (Vercel)
3441
This repository is configured for Vercel (`vercel.json`).
3542

app.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,28 @@ const createMessage = (role, text, meta = "") => {
5252
if (meta) {
5353
const metaEl = document.createElement("div");
5454
metaEl.className = "message-meta";
55-
metaEl.textContent = meta;
55+
56+
// Render KB refs as clickable links when present: "... • Refs: ID1, ID2"
57+
const refsMatch = /Refs:\s*(.+)$/i.exec(meta);
58+
if (refsMatch) {
59+
const before = meta.slice(0, refsMatch.index).trim();
60+
if (before) metaEl.appendChild(document.createTextNode(before + " "));
61+
62+
const ids = refsMatch[1].split(/\s*,\s*/).filter(Boolean);
63+
ids.forEach((id, idx) => {
64+
const a = document.createElement('a');
65+
a.href = apiUrl(`/kb/${encodeURIComponent(id)}`);
66+
a.target = '_blank';
67+
a.rel = 'noopener noreferrer';
68+
a.className = 'kb-ref';
69+
a.textContent = id;
70+
metaEl.appendChild(a);
71+
if (idx < ids.length - 1) metaEl.appendChild(document.createTextNode(', '));
72+
});
73+
} else {
74+
metaEl.textContent = meta;
75+
}
76+
5677
bubble.appendChild(metaEl);
5778
}
5879

@@ -125,6 +146,16 @@ const sendMessage = async (message) => {
125146
const payload = await response.json().catch(() => null);
126147

127148
if (!response.ok) {
149+
// If the server returned a helpful `text` field, show it to the user instead
150+
const serverText = payload?.text || payload?.error;
151+
if (serverText) {
152+
const laneMeta = payload?.lane === "strict" ? "Compliance mode • Verified KB only" : "Community mode";
153+
const refs = Array.isArray(payload?.kbRefs) && payload.kbRefs.length ? ` • Refs: ${payload.kbRefs.join(", ")}` : "";
154+
addMessage("bot", serverText, `${laneMeta}${refs}`);
155+
setStatus("online", "Online");
156+
return;
157+
}
158+
128159
setStatusBanner(response.status === 404 ? API_BASE_ERROR_MESSAGE : DEFAULT_OFFLINE_MESSAGE);
129160
throw new Error(payload?.error ? `${payload.error} (HTTP ${response.status})` : `Request failed: HTTP ${response.status}`);
130161
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"start": "node server.js",
99
"dev": "NODE_ENV=development node server.js",
10-
"test": "node --check server.js && node --check router.js && node --check localResponder.js && node --check app.js"
10+
"test": "node test/smoke.js"
1111
},
1212
"engines": {
1313
"node": ">=18.0.0"

server.js

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ if (!OPENAI_API_KEY) {
8383
logger.warn("OPENAI_API_KEY is missing. Chat responses will use safe fallback guidance only.");
8484
}
8585

86+
// In production, require ADMIN_KEY and ALLOWED_ORIGINS to be explicitly configured
87+
if (IS_PROD) {
88+
if (!ADMIN_KEY) {
89+
logger.error("ADMIN_KEY is required in production. Set ADMIN_KEY in environment.");
90+
// Fail fast in production to avoid accidentally exposing admin endpoints.
91+
process.exit(1);
92+
}
93+
94+
if (!ALLOWED_ORIGINS.length) {
95+
logger.error("ALLOWED_ORIGINS must be set in production to restrict CORS.");
96+
process.exit(1);
97+
}
98+
}
99+
86100
// -----------------------------
87101
// OpenAI client
88102
// -----------------------------
@@ -209,7 +223,19 @@ app.set("trust proxy", 1);
209223
// Security headers
210224
app.use(
211225
helmet({
212-
contentSecurityPolicy: false,
226+
// In production enable a reasonable CSP; disable only when developing locally.
227+
contentSecurityPolicy: IS_PROD
228+
? {
229+
directives: {
230+
defaultSrc: ["'self'"],
231+
scriptSrc: ["'self'"],
232+
styleSrc: ["'self'", "https:"] ,
233+
imgSrc: ["'self'", 'data:'],
234+
connectSrc: ["'self'"],
235+
frameAncestors: ["'self'"],
236+
},
237+
}
238+
: false,
213239
crossOriginEmbedderPolicy: false,
214240
})
215241
);
@@ -221,9 +247,7 @@ app.use(
221247
// allow same-origin / curl / server-to-server requests
222248
if (!origin) return cb(null, true);
223249

224-
// if not configured, default open for MVP
225-
if (!ALLOWED_ORIGINS.length) return cb(null, true);
226-
250+
// In production ALLOWED_ORIGINS is required (validated at startup).
227251
if (ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
228252
return cb(new Error(`CORS blocked: ${origin}`), false);
229253
},
@@ -254,7 +278,8 @@ app.use(
254278
stream: {
255279
write: (msg) => logger.info(msg.trim()),
256280
},
257-
skip: () => IS_PROD === false,
281+
// In development we want request logs visible. Only skip morgan in production.
282+
skip: () => IS_PROD === true,
258283
})
259284
);
260285

@@ -351,6 +376,29 @@ app.get("/metrics", (req, res) => {
351376
});
352377
});
353378

379+
// Serve knowledge base entry (simple JSON) for frontend citations
380+
app.get('/kb/:id', (req, res) => {
381+
try {
382+
const kb = knowledge.getJson();
383+
if (!kb) return res.status(503).json({ error: 'Knowledge base not available' });
384+
385+
const id = req.params.id;
386+
let entry = null;
387+
388+
if (Array.isArray(kb.documents)) {
389+
entry = kb.documents.find((d) => d.id === id);
390+
} else if (kb[id]) {
391+
entry = kb[id];
392+
}
393+
394+
if (!entry) return res.status(404).json({ error: 'KB entry not found' });
395+
return res.json({ id, entry });
396+
} catch (e) {
397+
logger.error('KB fetch error', { error: e?.message || String(e) });
398+
return res.status(500).json({ error: 'KB lookup failed' });
399+
}
400+
});
401+
354402
// -----------------------------
355403
// Admin endpoints
356404
// - In production: requires ADMIN_KEY via x-admin-key header OR {adminKey} body
@@ -378,6 +426,30 @@ app.post("/admin/knowledge/reload", requireAdmin, async (req, res) => {
378426
}
379427
});
380428

429+
// Admin: upload new knowledge JSON (protected in production)
430+
app.post('/admin/knowledge/upload', requireAdmin, express.json({ limit: '1mb' }), async (req, res) => {
431+
try {
432+
const payload = req.body;
433+
if (!payload || (typeof payload !== 'object')) {
434+
return res.status(400).json({ ok: false, error: 'Invalid JSON payload', requestId: req.requestId });
435+
}
436+
437+
// Basic validation: must contain metadata and documents OR be object-based
438+
const hasDocs = Array.isArray(payload.documents) && payload.documents.length > 0;
439+
const hasObj = Object.keys(payload).length > 0 && (payload.metadata || hasDocs);
440+
if (!hasDocs && !hasObj) {
441+
return res.status(400).json({ ok: false, error: 'Knowledge JSON missing required fields', requestId: req.requestId });
442+
}
443+
444+
await fs.writeFile(KNOWLEDGE_PATH, JSON.stringify(payload, null, 2), 'utf8');
445+
await knowledge.load(true);
446+
return res.json({ ok: true, updated: true, requestId: req.requestId });
447+
} catch (e) {
448+
logger.error('KB upload failed', { error: e?.message || String(e) });
449+
return res.status(500).json({ ok: false, error: 'KB upload failed', requestId: req.requestId });
450+
}
451+
});
452+
381453
// -----------------------------
382454
// Chat endpoint - UNIFIED MODE
383455
// Body: { message: string, stream?: boolean }
@@ -639,8 +711,13 @@ app.use((err, req, res, _next) => {
639711
error: err?.message || String(err),
640712
stack: err?.stack,
641713
});
714+
const userMessage = IS_PROD
715+
? "Server error. Please try again later or contact support."
716+
: (err?.message || "Error") + (err?.stack ? `\n${err.stack}` : "");
717+
642718
res.status(500).json({
643719
error: IS_PROD ? "Internal server error" : (err?.message || "Error"),
720+
text: userMessage,
644721
requestId: req.requestId,
645722
});
646723
});

test/smoke.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { spawn } from 'child_process';
2+
3+
const PORT = process.env.PORT || 3001;
4+
const SERVER_URL = `http://localhost:${PORT}`;
5+
6+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
7+
8+
(async () => {
9+
console.log('Starting smoke test...');
10+
11+
const server = spawn(process.execPath, ['server.js'], {
12+
env: { ...process.env, PORT: String(PORT) },
13+
stdio: ['ignore', 'inherit', 'inherit'],
14+
});
15+
16+
try {
17+
// wait for /health to be OK
18+
const deadline = Date.now() + 15_000;
19+
let healthy = false;
20+
while (Date.now() < deadline) {
21+
try {
22+
const r = await fetch(`${SERVER_URL}/health`);
23+
if (r.ok) {
24+
healthy = true;
25+
break;
26+
}
27+
} catch (e) {
28+
// ignore
29+
}
30+
await sleep(500);
31+
}
32+
33+
if (!healthy) throw new Error('Server did not become healthy in time');
34+
console.log('Server healthy. Running chat test...');
35+
36+
const r2 = await fetch(`${SERVER_URL}/chat`, {
37+
method: 'POST',
38+
headers: { 'Content-Type': 'application/json' },
39+
body: JSON.stringify({ message: 'What are the steps to get OPT?' }),
40+
});
41+
42+
if (!r2.ok) {
43+
const txt = await r2.text().catch(() => '');
44+
throw new Error(`Chat endpoint failed: ${r2.status} ${txt}`);
45+
}
46+
47+
const payload = await r2.json();
48+
if (!payload || !payload.text) throw new Error('Chat returned no text');
49+
50+
console.log('Chat returned text, kbRefs:', payload.kbRefs || []);
51+
console.log('Smoke test passed.');
52+
} catch (e) {
53+
console.error('Smoke test failed:', e?.message || String(e));
54+
process.exitCode = 2;
55+
} finally {
56+
try { server.kill(); } catch {}
57+
}
58+
})();

0 commit comments

Comments
 (0)