-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
153 lines (137 loc) · 6.36 KB
/
Copy pathserver.js
File metadata and controls
153 lines (137 loc) · 6.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// Mitos: semantic search sidecar for FileMaker. One field, any table, the
// right record. FileMaker (or the bundled sample data standing in for it) is
// the source of truth: everything displayed comes verbatim from source rows.
// AI output is used only as embedding fodder, never shown as record data.
// See README.md and RULES.md.
import "./env.js";
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { fmConfigured } from "./fm.js";
import { indexStats } from "./store.js";
import { buildIndex, indexManifest, isBuilding, tablesConfig } from "./indexer.js";
import { search } from "./search.js";
import { embedInfo } from "./providers.js";
import { enrichInfo } from "./enrich.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 8080);
const SITE_PASSWORD = process.env.SITE_PASSWORD || "";
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const app = express();
// Password gate (Pythia/Hecate pattern): if SITE_PASSWORD is set, the whole
// site requires it. Accepted three ways so it works in a browser AND a
// FileMaker web viewer (which cannot answer a Basic-auth prompt):
// 1. ?key=<password> in the URL -> also drops a cookie for later calls.
// 2. a mitos_auth cookie (set by #1).
// 3. HTTP Basic (browser prompt / curl -u).
if (SITE_PASSWORD) {
const cookieVal = `mitos_auth=${encodeURIComponent(SITE_PASSWORD)}`;
app.use((req, res, next) => {
if (req.query.key === SITE_PASSWORD) {
const secure = req.secure || req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
res.setHeader("Set-Cookie", `${cookieVal}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure}`);
return next();
}
const cookies = req.headers.cookie || "";
if (cookies.split(";").some((c) => c.trim() === cookieVal)) return next();
const header = req.headers.authorization || "";
if (header.startsWith("Basic ")) {
const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
if (decoded.slice(decoded.indexOf(":") + 1) === SITE_PASSWORD) return next();
}
res.set("WWW-Authenticate", 'Basic realm="Mitos"');
return res.status(401).send("Authentication required. Load with ?key=<password> in a web viewer.");
});
}
// No caching: FileMaker web viewers cache aggressively.
app.use((_req, res, next) => {
res.set("Cache-Control", "no-store, no-cache, must-revalidate");
next();
});
app.use(express.json({ limit: "1mb" }));
app.use(express.static(path.join(__dirname, "public")));
// --- SSE progress bus for index builds ---------------------------------------
const sseClients = new Set();
function broadcast(event) {
const line = `data: ${JSON.stringify(event)}\n\n`;
for (const res of sseClients) res.write(line);
}
app.get("/api/index/stream", (req, res) => {
res.set({ "Content-Type": "text/event-stream", Connection: "keep-alive" });
res.flushHeaders();
res.write(`data: ${JSON.stringify({ type: "hello", building: isBuilding() })}\n\n`);
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
});
// --- API ----------------------------------------------------------------------
app.get("/api/health", async (_req, res) => {
const stats = await indexStats().catch(() => ({ tables: [], totalRows: 0 }));
res.json({
ok: true,
app: "mitos",
fm: fmConfigured,
building: isBuilding(),
indexRows: stats.totalRows,
embed: embedInfo(),
enrich: enrichInfo(),
});
});
// GET so a FileMaker web viewer or Insert From URL can hit it with ?key=.
app.get("/api/search", async (req, res) => {
const query = String(req.query.q || "").trim();
if (!query) return res.status(400).json({ error: "q is required" });
const limit = Math.min(Math.max(Number(req.query.limit) || 5, 1), 20);
const enrich = req.query.enrich === undefined ? undefined : req.query.enrich !== "0";
try {
const result = await search(query, { limit, ...(enrich === undefined ? {} : { enrich }) });
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post("/api/index/sync", (_req, res) => {
if (isBuilding()) return res.status(409).json({ error: "build already running" });
buildIndex({ onEvent: broadcast }).catch((e) => broadcast({ type: "error", message: e.message }));
res.json({ ok: true, started: true });
});
app.get("/api/index/status", async (_req, res) => {
res.json({
building: isBuilding(),
manifest: indexManifest(),
stats: await indexStats().catch(() => ({ tables: [], totalRows: 0 })),
tables: Object.keys(tablesConfig()),
});
});
// Click beacon stub: appends to a JSONL log. Feeds the future per-user
// learning loop; today it just records what got clicked for which query.
app.post("/api/click", (req, res) => {
const { query, table, record_id } = req.body || {};
if (!table || !record_id) return res.status(400).json({ error: "table and record_id required" });
const line = JSON.stringify({ ts: new Date().toISOString(), query: query || "", table, record_id }) + "\n";
fs.appendFile(path.join(DATA_DIR, "clicks.jsonl"), line, () => {});
res.json({ ok: true });
});
// --- Boot ---------------------------------------------------------------------
app.listen(PORT, async () => {
console.log(`Mitos listening on http://localhost:${PORT}`);
console.log(` fm: ${fmConfigured ? "configured" : "not configured (sample mode)"}`);
console.log(` embed: ${JSON.stringify(embedInfo())}`);
console.log(` enrich: ${JSON.stringify(enrichInfo())}`);
// Out-of-box play: if the index is empty and keys are set, build in the
// background so a fresh clone is searchable a minute or two after start.
try {
const stats = await indexStats();
if (!stats.totalRows && embedInfo().configured && enrichInfo().configured) {
console.log("index empty; starting background build...");
buildIndex({ onEvent: (e) => { broadcast(e); if (e.type !== "enrich-progress") console.log(" build:", JSON.stringify(e)); } })
.then(() => console.log("index build complete"))
.catch((e) => console.error("index build failed:", e.message));
} else if (!stats.totalRows) {
console.log("index empty; set ANTHROPIC_API_KEY and EMBED_API_KEY, then POST /api/index/sync");
}
} catch (e) {
console.error("boot check failed:", e.message);
}
});