476 lines
24 KiB
TypeScript

import express from "express";
import {
acknowledgeAgentMessage,
enrichStoryEvidenceDocuments,
getOpportunity,
getStoryThread,
listAgentMessages,
listOpportunities,
listStoryThreads,
markOpportunity,
markStoryThreadStatus,
migrate,
pool,
seedInitialStoryThread,
seedSources
} from "./db.js";
import {
beginWebLogin,
finishWebLogin,
logoutWeb,
protectedResourceMetadata,
requireMcpAuth,
requireWebAuth
} from "./auth.js";
import { handleMcp } from "./mcp.js";
import { SOURCES } from "./sources.js";
import { syncAll } from "./sync.js";
const app = express();
app.set("trust proxy", true);
app.use(express.json({ limit: "2mb" }));
app.use(express.urlencoded({ extended: false }));
function escapeHtml(value: unknown) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function nzTime(value: unknown) {
if (!value) return "";
try {
return new Intl.DateTimeFormat("en-NZ", {
timeZone: "Pacific/Auckland",
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(String(value)));
} catch {
return String(value);
}
}
function storyGraphSvg(story: any) {
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const width = 1000;
const height = 640;
const cx = width / 2;
const cy = height / 2 + 20;
const rx = 390;
const ry = 235;
const positions = new Map<string, { x: number; y: number }>();
nodes.forEach((node: any, index: number) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(nodes.length, 1);
positions.set(node.id, {
x: cx + Math.cos(angle) * rx,
y: cy + Math.sin(angle) * ry
});
});
const edgeSvg = edges.map((edge: any) => {
const from = positions.get(edge.from_node_id);
const to = positions.get(edge.to_node_id);
if (!from || !to) return "";
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
return `<g class="edge">
<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" marker-end="url(#arrow)"></line>
<text x="${mx}" y="${my - 5}" text-anchor="middle">${escapeHtml(edge.relationship)}</text>
</g>`;
}).join("");
const nodeSvg = nodes.map((node: any) => {
const p = positions.get(node.id)!;
const title = String(node.title ?? "");
const short = title.length > 36 ? title.slice(0, 33) + "…" : title;
return `<a href="#node-${encodeURIComponent(node.id)}">
<g class="graph-node ${escapeHtml(node.node_type)}">
<title>${escapeHtml(node.title)} — ${escapeHtml(node.node_type)}</title>
<rect x="${p.x - 100}" y="${p.y - 38}" width="200" height="76" rx="14"></rect>
<text class="node-type" x="${p.x}" y="${p.y - 13}" text-anchor="middle">${escapeHtml(node.node_type)}</text>
<text class="node-title" x="${p.x}" y="${p.y + 10}" text-anchor="middle">${escapeHtml(short)}</text>
</g>
</a>`;
}).join("");
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Evidence network for ${escapeHtml(story.title)}">
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"></path></marker></defs>
<g class="thread-centre">
<rect x="${cx - 125}" y="${cy - 48}" width="250" height="96" rx="18"></rect>
<text x="${cx}" y="${cy - 8}" text-anchor="middle">STORY THREAD</text>
<text x="${cx}" y="${cy + 18}" text-anchor="middle">${escapeHtml(String(story.title).slice(0, 38))}</text>
</g>
${edgeSvg}
${nodeSvg}
</svg>`;
}
app.get("/health", async (_req, res) => {
try {
await pool.query("SELECT 1");
res.json({ ok: true, service: "media-intelligence", version: "0.1.0" });
} catch (error) {
res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/ready", async (_req, res) => {
try {
const result = await pool.query(
`SELECT s.id, s.name, s.last_synced_at, s.last_error,
COUNT(sig.id)::int AS signal_count
FROM sources s
LEFT JOIN signals sig ON sig.source_id=s.id
GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete
ORDER BY s.id`
);
const missing = result.rows
.filter((row: any) => Number(row.signal_count) < 1)
.map((row: any) => row.id);
const errored = result.rows
.filter((row: any) => row.last_error)
.map((row: any) => row.id);
if (missing.length || errored.length) {
return res.status(503).json({
ok: false,
missing_sources: missing,
errored_sources: errored,
sources: result.rows
});
}
return res.json({ ok: true, sources: result.rows });
} catch (error) {
return res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
app.get("/.well-known/oauth-protected-resource", (req, res) =>
res.json(protectedResourceMetadata(req))
);
app.get("/.well-known/oauth-protected-resource/api/mcp", (req, res) =>
res.json(protectedResourceMetadata(req))
);
app.get("/auth/login", beginWebLogin);
app.get("/auth/callback", finishWebLogin);
app.get("/auth/logout", logoutWeb);
app.post("/api/mcp", requireMcpAuth, handleMcp);
app.get("/api/mcp", requireMcpAuth, (_req, res) => {
res.status(405).setHeader("Allow", "POST").json({
error: "Use POST for MCP JSON-RPC requests"
});
});
app.post("/stories/:id/status", requireWebAuth, async (req, res) => {
const user = (req as any).user ?? {};
const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user");
try {
await markStoryThreadStatus(String(req.params.id), String(req.body.status ?? ""), `web:${identity}`);
return res.redirect(`/stories/${encodeURIComponent(String(req.params.id))}`);
} catch (error) {
return res.status(400).type("text").send(error instanceof Error ? error.message : String(error));
}
});
app.get("/stories/:id", requireWebAuth, async (req, res) => {
const story = await getStoryThread(String(req.params.id));
if (!story) return res.status(404).send("Story thread not found");
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
const edges = Array.isArray(story.edges) ? story.edges : [];
const checks = Array.isArray(story.research_checks) ? story.research_checks : [];
const counter = checks.find((c:any)=>c.check_type === "counter_evidence");
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(story.title)} — Story Research</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:1180px;margin:32px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:18px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
.meta{font-size:.86rem;color:#6b7280}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
button{padding:8px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
.graph-wrap{overflow:auto;border:1px solid #d1d5db;border-radius:14px;background:#fafafa;margin:18px 0}
.story-graph{display:block;min-width:900px;width:100%;height:auto}.edge line{stroke:#9ca3af;stroke-width:2}.edge text{font-size:12px;fill:#4b5563;paint-order:stroke;stroke:#fafafa;stroke-width:5px}
#arrow path{fill:#9ca3af}.thread-centre rect{fill:#111827}.thread-centre text{fill:#fff;font-size:13px}.graph-node rect{fill:#fff;stroke:#9ca3af;stroke-width:2}
.graph-node.evidence rect,.graph-node.actual rect{fill:#eff6ff;stroke:#2563eb}.graph-node.claim rect{fill:#ecfdf5;stroke:#059669}.graph-node.counterclaim rect{fill:#fef2f2;stroke:#dc2626}
.graph-node.question rect{fill:#fff7ed;stroke:#ea580c}.graph-node.forecast rect,.graph-node.trend rect{fill:#faf5ff;stroke:#9333ea}.graph-node.context rect{fill:#f8fafc;stroke:#64748b}
.node-type{font-size:10px;text-transform:uppercase;fill:#6b7280}.node-title{font-size:12px;font-weight:650;fill:#111827}
.node-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}.node-card{scroll-margin-top:20px}
.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
.check-ok{border-left:5px solid #059669}.check-pending{border-left:5px solid #d97706}
</style></head><body>
<header><div><a href="/stories">← Story Threads</a><h1>${escapeHtml(story.title)}</h1><p>${escapeHtml(story.question)}</p></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
<div class="card">
<strong>Status:</strong> ${escapeHtml(story.status)} ·
<strong>Nodes:</strong> ${nodes.length} ·
<strong>Edges:</strong> ${edges.length}
<div class="toolbar">
${["researching","review","develop","closed"].map(s=>`<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
</div>
</div>
<div class="card ${counter ? 'check-ok' : 'check-pending'}">
<strong>Mandatory counter-evidence pass:</strong>
${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}<p>${escapeHtml(counter.summary)}</p>` : "not completed"}
</div>
<div class="graph-wrap">${storyGraphSvg(story)}</div>
<h2>Research nodes</h2>
<div class="node-list">
${nodes.map((n:any)=>`<div class="card node-card" id="node-${escapeHtml(n.id)}">
<span class="badge">${escapeHtml(n.node_type)}</span>
<h3>${escapeHtml(n.title)}</h3>
<p>${escapeHtml(n.summary)}</p>
<div class="meta">${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}</div>
${n.evidence_url ? `<p><a href="${escapeHtml(n.evidence_url)}" target="_blank" rel="noreferrer">Open evidence ↗</a></p>` : ""}
</div>`).join("")}
</div>
<h2>Relationships</h2>
${edges.length ? edges.map((e:any)=>`<div class="card"><strong>${escapeHtml(e.relationship)}</strong><br><code>${escapeHtml(e.from_node_id.slice(0,8))}</code> → <code>${escapeHtml(e.to_node_id.slice(0,8))}</code><p>${escapeHtml(e.reason)}</p></div>`).join("") : '<div class="card meta">No graph edges yet.</div>'}
</body></html>`);
});
app.get("/stories", requireWebAuth, async (req, res) => {
const stories = await listStoryThreads({ limit: 100 });
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Story Threads — Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:20px}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}.meta{font-size:.86rem;color:#6b7280}
.title{font-size:1.08rem;font-weight:700;text-decoration:none}a{color:inherit}.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
</style></head><body>
<header><div><a href="/">← Dashboard</a><h1>360° Story Threads</h1><p>Evidence networks that require an explicit counter-evidence pass before editorial review.</p></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
${stories.length ? stories.map((s:any)=>`<div class="card">
<a class="title" href="/stories/${encodeURIComponent(s.id)}">${escapeHtml(s.title)}</a>
<p>${escapeHtml(s.question)}</p>
<div class="meta"><span class="badge">${escapeHtml(s.status)}</span> · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}</div>
</div>`).join("") : '<div class="card meta">No story threads yet.</div>'}
</body></html>`);
});
app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
const user = (req as any).user ?? {};
const identity = String(
user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"
);
await acknowledgeAgentMessage(String(req.params.id), `web:${identity}`);
return res.redirect("/");
});
app.post("/opportunities/:id/status", requireWebAuth, async (req, res) => {
const id = String(req.params.id);
const status = String(req.body.status ?? "");
await markOpportunity(id, status);
return res.redirect(req.body.returnTo === "detail" ? `/opportunities/${encodeURIComponent(id)}` : "/opportunities");
});
app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
const opportunity = await getOpportunity(String(req.params.id));
if (!opportunity) return res.status(404).send("Opportunity not found");
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
const evidence = Array.isArray(opportunity.evidence) ? opportunity.evidence : [];
const notes = Array.isArray(opportunity.notes) ? opportunity.notes : [];
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(opportunity.title)} — Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:960px;margin:36px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:14px 0}
.meta{font-size:.88rem;color:#6b7280}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}
button{padding:8px 12px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}a{color:inherit}
pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f9fafb;padding:12px;border-radius:8px}
</style></head><body>
<header><div><a href="/opportunities">← Opportunities</a><h1>${escapeHtml(opportunity.title)}</h1></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
<div class="card">
<div class="meta">${escapeHtml(opportunity.source_id)} · ${escapeHtml(nzTime(opportunity.published_at || opportunity.detected_at))}</div>
<p>${escapeHtml(opportunity.summary)}</p>
<p><strong>Status:</strong> ${escapeHtml(opportunity.status)} · <strong>Score:</strong> ${escapeHtml(opportunity.score)}</p>
<p><strong>Why surfaced:</strong> ${escapeHtml((opportunity.reasons ?? []).join(" · "))}</p>
<p><a href="${escapeHtml(opportunity.url)}" target="_blank" rel="noreferrer">Open original source ↗</a></p>
<div class="actions">
${["watch","investigate","develop","ignore","closed"].map(status=>`<form method="post" action="/opportunities/${encodeURIComponent(opportunity.id)}/status"><input type="hidden" name="status" value="${status}"><input type="hidden" name="returnTo" value="detail"><button type="submit">${status}</button></form>`).join("")}
</div>
</div>
<h2>Evidence</h2>
${evidence.length ? evidence.map((e:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(e.retrieved_at))} · <code>${escapeHtml(e.content_hash?.slice(0,12))}</code></div><pre>${escapeHtml(e.excerpt)}</pre><a href="${escapeHtml(e.source_url)}" target="_blank" rel="noreferrer">Source ↗</a></div>`).join("") : '<div class="card meta">No evidence stored.</div>'}
<h2>Notes</h2>
${notes.length ? notes.map((n:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(n.created_at))}</div><p>${escapeHtml(n.note)}</p></div>`).join("") : '<div class="card meta">No notes yet.</div>'}
</body></html>`);
});
app.get("/opportunities", requireWebAuth, async (req, res) => {
const requested = String(req.query.status ?? "");
const valid = new Set(["new","watch","investigate","develop","ignore","closed"]);
const status = valid.has(requested) ? requested : undefined;
const opportunities = await listOpportunities({ status, limit: 100 });
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Opportunities — Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
.meta{font-size:.86rem;color:#6b7280}.filters{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0}.filters a{padding:7px 10px;border:1px solid #d1d5db;border-radius:8px;text-decoration:none}
.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}button{padding:7px 10px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
.title{font-size:1.05rem;font-weight:700;text-decoration:none}
</style></head><body>
<header><div><a href="/">← Dashboard</a><h1>Media Opportunities</h1><p>Human triage queue. No release drafting or distribution happens here.</p></div>
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
<div class="filters">
<a href="/opportunities">Active</a>
${["new","watch","investigate","develop","ignore","closed"].map(s=>`<a href="/opportunities?status=${s}">${s}</a>`).join("")}
</div>
${opportunities.length ? opportunities.map((o:any)=>`<div class="card">
<a class="title" href="/opportunities/${encodeURIComponent(o.id)}">${escapeHtml(o.title)}</a>
<div class="meta">${escapeHtml(o.source_id)} · ${escapeHtml(nzTime(o.published_at || o.detected_at))} · status ${escapeHtml(o.status)} · score ${escapeHtml(o.score)}</div>
<p>${escapeHtml(o.summary)}</p>
<div class="meta">${escapeHtml((o.reasons ?? []).join(" · "))}</div>
<div class="actions">
${["watch","investigate","develop","ignore"].map(s=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
</div>
</div>`).join("") : '<div class="card meta">No opportunities in this view.</div>'}
</body></html>`);
});
app.get("/", requireWebAuth, async (req, res) => {
const sourceRows = await pool.query(
`SELECT s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete,
COUNT(sig.id)::int AS signal_count
FROM sources s
LEFT JOIN signals sig ON sig.source_id=s.id
GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete
ORDER BY s.id`
);
const counts = await pool.query(`
SELECT
(SELECT COUNT(*) FROM signals) AS signals,
(SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities,
(SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities,
(SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages,
(SELECT COUNT(*) FROM story_threads WHERE status <> 'closed') AS story_threads
`);
const messages = await listAgentMessages({ acknowledged: false, limit: 10 });
const c = counts.rows[0] ?? {};
const user = (req as any).user ?? {};
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
res.type("html").send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:40px auto;padding:0 20px;color:#1f2937}
header{display:flex;align-items:center;justify-content:space-between;gap:16px}
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:12px 0}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
strong.big{font-size:2rem;display:block}
.ok{color:#166534}.bad{color:#991b1b}
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
a{color:inherit}
.user{font-size:.9rem;color:#4b5563;text-align:right}
.message{border-left-width:5px}.message.notice{border-left-color:#2563eb}.message.warning{border-left-color:#d97706}.message.error{border-left-color:#b91c1c}
.message-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.message-body{white-space:pre-wrap;line-height:1.45}
.meta{font-size:.84rem;color:#6b7280}
button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
</style></head><body>
<header>
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p><p><a href="/opportunities">Open Opportunities →</a> · <a href="/stories">Open 360° Story Threads →</a></p></div>
<div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div>
</header>
<div class="grid">
<div class="card"><strong class="big">${c.signals ?? 0}</strong>signals</div>
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
<div class="card"><strong class="big">${c.unread_messages ?? 0}</strong>agent messages</div>
<div class="card"><strong class="big">${c.story_threads ?? 0}</strong>story threads</div>
</div>
<h2>Agent Activity</h2>
${messages.length
? messages.map((m:any)=>`<div class="card message ${escapeHtml(m.severity)}">
<div class="message-head">
<div><strong>${escapeHtml(m.title)}</strong><br><span class="meta">${escapeHtml(m.kind)} · ${escapeHtml(m.severity)}</span></div>
<div class="meta">${escapeHtml(nzTime(m.created_at))}</div>
</div>
<p class="message-body">${escapeHtml(m.body)}</p>
<div class="meta">From ${escapeHtml(m.actor_name)} via <code>${escapeHtml(m.actor_client_id)}</code>${m.related_type ? ` · ${escapeHtml(m.related_type)} <code>${escapeHtml(m.related_id ?? "")}</code>` : ""}</div>
<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack"><button type="submit">Acknowledge</button></form>
</div>`).join("")
: '<div class="card"><span class="meta">No unread agent messages.</span></div>'}
<h2>Sources</h2>
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code> · ${s.baseline_complete ? 'baseline ready' : 'awaiting baseline'}<br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
<p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p>
</body></html>`);
});
async function start() {
await migrate();
await seedSources(SOURCES);
await seedInitialStoryThread();
const port = Number(process.env.PORT ?? 3000);
app.listen(port, "0.0.0.0", () => {
console.log(JSON.stringify({ event: "server_started", port, version: "0.1.0" }));
});
const intervalMinutes = Math.max(
Number(process.env.SYNC_INTERVAL_MINUTES ?? 15),
5
);
setTimeout(
() =>
enrichStoryEvidenceDocuments()
.then((result) =>
console.log(JSON.stringify({ event: "story_evidence_enrichment", result }))
)
.catch((error) => console.error(error)),
2500
);
setTimeout(
() =>
syncAll()
.then((r) =>
console.log(JSON.stringify({ event: "initial_sync", result: r }))
)
.catch((e) => console.error(e)),
5000
);
setInterval(
() =>
syncAll()
.then((r) =>
console.log(JSON.stringify({ event: "scheduled_sync", result: r }))
)
.catch((e) => console.error(e)),
intervalMinutes * 60_000
);
}
start().catch((error) => {
console.error(error);
process.exit(1);
});