diff --git a/src/auth.ts b/src/auth.ts index 954a133..dafd980 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -59,6 +59,77 @@ function safeReturnTo(value: unknown) { return text.startsWith("/") && !text.startsWith("//") ? text : "/"; } +export type MediaRole = "viewer" | "editor" | "admin"; + +const MEDIA_ROLE_RANK: Record = { + viewer: 1, + editor: 2, + admin: 3 +}; + +function roleNamesFromClaims(claims: Record) { + const roles = new Set(); + + const realmRoles = claims?.realm_access?.roles; + if (Array.isArray(realmRoles)) { + for (const role of realmRoles) roles.add(String(role)); + } + + const resourceAccess = claims?.resource_access; + if (resourceAccess && typeof resourceAccess === "object") { + for (const value of Object.values(resourceAccess as Record)) { + if (Array.isArray(value?.roles)) { + for (const role of value.roles) roles.add(String(role)); + } + } + } + + const directRoles = claims?.roles; + if (Array.isArray(directRoles)) { + for (const role of directRoles) roles.add(String(role)); + } + + return Array.from(roles); +} + +export function mediaRoleFromClaims(claims: Record): MediaRole { + const roles = roleNamesFromClaims(claims).map((role) => role.toLowerCase()); + + if (roles.includes("media-intelligence-admin")) return "admin"; + if (roles.includes("media-intelligence-editor")) return "editor"; + if (roles.includes("media-intelligence-viewer")) return "viewer"; + + if (roles.some((role) => /(^|[-_])(owner|admin)$/.test(role))) return "admin"; + if (roles.some((role) => /(^|[-_])(operator|developer)$/.test(role))) return "editor"; + if (roles.some((role) => /(^|[-_])viewer$/.test(role))) return "viewer"; + + // Fail safe: authenticated users with no recognised mapping can read but not mutate. + return "viewer"; +} + +export function mediaRoleAtLeast(role: MediaRole, required: MediaRole) { + return MEDIA_ROLE_RANK[role] >= MEDIA_ROLE_RANK[required]; +} + +export function requireWebRole(required: MediaRole) { + return (req: Request, res: Response, next: NextFunction) => { + const user = ((req as any).user ?? {}) as Record; + const role = (user.media_role as MediaRole | undefined) ?? mediaRoleFromClaims(user); + if (!mediaRoleAtLeast(role, required)) { + return res.status(403).type("text").send( + `This action requires the ${required} role. Your current Media Intelligence role is ${role}.` + ); + } + return next(); + }; +} + +export function webMediaRole(req: Request): MediaRole { + const user = ((req as any).user ?? {}) as Record; + return (user.media_role as MediaRole | undefined) ?? mediaRoleFromClaims(user); +} + + function webConfig(req: Request) { const issuer = process.env.WEB_OIDC_ISSUER || process.env.KEYCLOAK_ISSUER; const clientId = process.env.WEB_OIDC_CLIENT_ID; @@ -141,7 +212,11 @@ export async function requireMcpAuth(req: Request, res: Response, next: NextFunc if (payload.azp !== clientId && !aud.includes(clientId)) { return res.status(403).json({ error: "OAuth token was not issued for this MCP client" }); } - (req as any).auth = payload; + (req as any).auth = { + ...payload, + media_role: mediaRoleFromClaims(payload as Record), + role_names: roleNamesFromClaims(payload as Record) + }; return next(); } catch { return res.status(401).json({ error: "Invalid OAuth access token" }); @@ -238,11 +313,39 @@ export async function finishWebLogin(req: Request, res: Response) { return res.status(401).send("OIDC nonce validation failed."); } + let accessPayload: Record | null = null; + if (tokens.access_token) { + try { + const verified = await jwtVerify(tokens.access_token, webJwks, { + issuer: cfg.issuer + }); + accessPayload = verified.payload as Record; + } catch { + accessPayload = null; + } + } + + const idClaims = payload as Record; + const mergedRoleNames = Array.from(new Set([ + ...roleNamesFromClaims(idClaims), + ...(accessPayload ? roleNamesFromClaims(accessPayload) : []) + ])); + const mediaRole = accessPayload + ? mediaRoleFromClaims({ + ...idClaims, + realm_access: accessPayload.realm_access ?? idClaims.realm_access, + resource_access: accessPayload.resource_access ?? idClaims.resource_access, + roles: mergedRoleNames + }) + : mediaRoleFromClaims(idClaims); + const session = await createSession({ sub: payload.sub, email: payload.email, preferred_username: payload.preferred_username, - name: payload.name + name: payload.name, + media_role: mediaRole, + role_names: mergedRoleNames }); res.append("Set-Cookie", cookie(SESSION_COOKIE, session, 8 * 60 * 60)); diff --git a/src/db.ts b/src/db.ts index f71dab8..11f2e51 100644 --- a/src/db.ts +++ b/src/db.ts @@ -145,6 +145,7 @@ export async function migrate() { opportunity_id TEXT REFERENCES opportunities(id) ON DELETE SET NULL, message_id TEXT, confidence NUMERIC(4,3), + review_state TEXT NOT NULL DEFAULT 'active', created_by_sub TEXT NOT NULL, created_by_name TEXT NOT NULL, created_by_client_id TEXT NOT NULL, @@ -253,6 +254,7 @@ export async function migrate() { await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS canonical_url TEXT"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS document_text TEXT"); await pool.query("ALTER TABLE source_snapshots ADD COLUMN IF NOT EXISTS http_status INTEGER"); + await pool.query("ALTER TABLE story_nodes ADD COLUMN IF NOT EXISTS review_state TEXT NOT NULL DEFAULT 'active'"); const payloadMarker = await pool.query( `INSERT INTO app_meta(key,value) @@ -1002,8 +1004,11 @@ export async function listStoryThreads(args: { } function buildStorySourceAudit(story: any) { - const nodes = Array.isArray(story.nodes) ? story.nodes : []; - const edges = Array.isArray(story.edges) ? story.edges : []; + const allNodes = Array.isArray(story.nodes) ? story.nodes : []; + const nodes = allNodes.filter((n: any) => n.review_state !== "irrelevant"); + const nodeIds = new Set(nodes.map((n: any) => n.id)); + const edges = (Array.isArray(story.edges) ? story.edges : []) + .filter((e: any) => nodeIds.has(e.from_node_id) && nodeIds.has(e.to_node_id)); const evidenceBacked = nodes.filter((n: any) => { if (!n.evidence_id || !n.snapshot_id || !n.provenance_source_id) return false; @@ -1112,6 +1117,57 @@ function buildStorySourceAudit(story: any) { }; } +function buildStoryResearchChecklist(story: any, sourceAudit: any) { + const nodes = Array.isArray(story.nodes) ? story.nodes : []; + const activeNodes = nodes.filter((n: any) => n.review_state !== "irrelevant"); + const counterDone = (story.research_checks ?? []).some( + (c: any) => c.check_type === "counter_evidence" && ["found","none_found"].includes(c.result) + ); + const unresolvedQuestions = activeNodes.filter( + (n: any) => n.node_type === "question" && n.review_state !== "resolved" + ); + + const items = [ + { + key: "primary_evidence", + label: "Primary/substantive evidence captured", + done: Number(sourceAudit?.counts?.evidence_backed_nodes ?? 0) >= 1 + }, + { + key: "counter_evidence", + label: "Counter-evidence pass completed", + done: counterDone + }, + { + key: "independent_corroboration", + label: "At least two independent source groups", + done: Number(sourceAudit?.counts?.distinct_independence_groups ?? 0) >= 2 + }, + { + key: "assertions_supported", + label: "All active assertions connected to substantive evidence", + done: Number(sourceAudit?.counts?.unsupported_claims ?? 0) === 0 + }, + { + key: "questions_resolved", + label: "Open research questions resolved or explicitly parked", + done: unresolvedQuestions.length === 0 + } + ]; + + return { + ready_for_editorial: Boolean(sourceAudit?.robust && counterDone), + completed: items.filter((item) => item.done).length, + total: items.length, + unresolved_questions: unresolvedQuestions.map((n: any) => ({ + id: n.id, + title: n.title, + summary: n.summary + })), + items + }; +} + export async function getStoryThread(id: string) { const threadResult = await pool.query( `SELECT t.*, @@ -1124,7 +1180,7 @@ export async function getStoryThread(id: string) { ); if (!threadResult.rowCount) return null; - const [nodes, edges, checks] = await Promise.all([ + const [nodes, edges, checks, requests] = await Promise.all([ pool.query( `SELECT n.*, COALESCE(n.source_id,s.source_id) AS provenance_source_id, @@ -1162,6 +1218,10 @@ export async function getStoryThread(id: string) { pool.query( "SELECT * FROM story_research_checks WHERE thread_id=$1 ORDER BY completed_at DESC", [id] + ), + pool.query( + "SELECT * FROM agent_messages WHERE related_type='story' AND related_id=$1 ORDER BY created_at DESC", + [id] ) ]); @@ -1169,12 +1229,15 @@ export async function getStoryThread(id: string) { ...threadResult.rows[0], nodes: nodes.rows, edges: edges.rows, - research_checks: checks.rows + research_checks: checks.rows, + research_requests: requests.rows }; + const sourceAudit = buildStorySourceAudit(story); return { ...story, - source_audit: buildStorySourceAudit(story) + source_audit: sourceAudit, + research_checklist: buildStoryResearchChecklist(story, sourceAudit) }; } @@ -1246,6 +1309,33 @@ export async function addStoryNode( return result.rows[0]; } +export async function setStoryNodeReviewState( + threadId: string, + nodeId: string, + reviewState: string, + actor: StoryActor +) { + const allowed = new Set(["active","resolved","irrelevant"]); + if (!allowed.has(reviewState)) throw new Error("Invalid story node review state"); + + const result = await pool.query( + `UPDATE story_nodes + SET review_state=$3 + WHERE id=$2 AND thread_id=$1 + RETURNING *`, + [threadId,nodeId,reviewState] + ); + if (!result.rowCount) throw new Error("Story node not found"); + + await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]); + await pool.query( + `INSERT INTO audit_events(id,event_type,object_type,object_id,payload) + VALUES($1,'story_node.review_state_changed','story_node',$2,$3::jsonb)`, + [randomUUID(), nodeId, JSON.stringify({ threadId, reviewState, actor })] + ); + return result.rows[0]; +} + export async function addStoryEdge( threadId: string, args: { @@ -1673,7 +1763,7 @@ export async function postAgentMessage( const kind = String(args.kind ?? "update"); const allowedSeverities = new Set(["info", "notice", "warning", "error"]); const allowedKinds = new Set(["update", "finding", "request", "warning", "error"]); - const allowedRelated = new Set(["opportunity", "source", "evidence", "general"]); + const allowedRelated = new Set(["opportunity", "source", "evidence", "story", "general"]); if (!allowedSeverities.has(severity)) throw new Error("Invalid message severity"); if (!allowedKinds.has(kind)) throw new Error("Invalid message kind"); diff --git a/src/index.ts b/src/index.ts index 9cc2127..11c99d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import express from "express"; import { acknowledgeAgentMessage, + addStoryNode, enrichStoryEvidenceDocuments, getOpportunity, getStoryThread, @@ -9,6 +10,8 @@ import { listStoryThreads, markOpportunity, markStoryThreadStatus, + postAgentMessage, + setStoryNodeReviewState, migrate, pool, seedInitialStoryThread, @@ -23,7 +26,9 @@ import { logoutWeb, protectedResourceMetadata, requireMcpAuth, - requireWebAuth + requireWebAuth, + requireWebRole, + webMediaRole } from "./auth.js"; import { handleMcp } from "./mcp.js"; import { SOURCES } from "./sources.js"; @@ -35,6 +40,16 @@ app.set("trust proxy", true); app.use(express.json({ limit: "2mb" })); app.use(express.urlencoded({ extended: false })); +function webActor(req: express.Request) { + const user = (req as any).user ?? {}; + return { + sub: String(user.sub ?? "web-user"), + name: String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"), + clientId: "media-intelligence-web" + }; +} + + function escapeHtml(value: unknown) { return String(value ?? "") .replace(/&/g, "&") @@ -174,7 +189,53 @@ app.get("/api/mcp", requireMcpAuth, (_req, res) => { }); -app.post("/stories/:id/status", requireWebAuth, async (req, res) => { +app.post("/stories/:id/nodes", requireWebAuth, requireWebRole("editor"), async (req, res) => { + const threadId = String(req.params.id); + try { + await addStoryNode(threadId, { + nodeType: String(req.body.node_type ?? "context"), + title: String(req.body.title ?? ""), + summary: String(req.body.summary ?? "") + }, webActor(req)); + return res.redirect(`/stories/${encodeURIComponent(threadId)}`); + } catch (error) { + return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); + } +}); + +app.post("/stories/:id/nodes/:nodeId/review", requireWebAuth, requireWebRole("editor"), async (req, res) => { + const threadId = String(req.params.id); + try { + await setStoryNodeReviewState( + threadId, + String(req.params.nodeId), + String(req.body.review_state ?? "active"), + webActor(req) + ); + return res.redirect(`/stories/${encodeURIComponent(threadId)}`); + } catch (error) { + return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); + } +}); + +app.post("/stories/:id/request-research", requireWebAuth, requireWebRole("editor"), async (req, res) => { + const threadId = String(req.params.id); + try { + await postAgentMessage({ + title: `Research request: ${String(req.body.title ?? "").trim()}`, + body: String(req.body.body ?? "").trim(), + severity: "notice", + kind: "request", + relatedType: "story", + relatedId: threadId + }, webActor(req)); + return res.redirect(`/stories/${encodeURIComponent(threadId)}`); + } catch (error) { + return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); + } +}); + +app.post("/stories/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); try { @@ -191,7 +252,8 @@ app.get("/stories/:id", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); - return res.type("html").send(renderStory({ story, identity })); + const role = webMediaRole(req); + return res.type("html").send(renderStory({ story, identity, role })); }); app.get("/stories", requireWebAuth, async (req, res) => { @@ -202,10 +264,11 @@ app.get("/stories", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); - return res.type("html").send(renderStories({ stories, identity })); + const role = webMediaRole(req); + return res.type("html").send(renderStories({ stories, identity, role })); }); -app.post("/messages/:id/ack", requireWebAuth, async (req, res) => { +app.post("/messages/:id/ack", requireWebAuth, requireWebRole("editor"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String( user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user" @@ -214,7 +277,7 @@ app.post("/messages/:id/ack", requireWebAuth, async (req, res) => { return res.redirect("/"); }); -app.post("/opportunities/:id/status", requireWebAuth, async (req, res) => { +app.post("/opportunities/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => { const id = String(req.params.id); const status = String(req.body.status ?? ""); await markOpportunity(id, status); @@ -225,10 +288,11 @@ app.get("/trust", requireWebAuth, async (req, res) => { const entries = await listSourceTrustPolicy(); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); - return res.type("html").send(renderTrustPolicy({ entries, identity })); + const role = webMediaRole(req); + return res.type("html").send(renderTrustPolicy({ entries, identity, role })); }); -app.post("/trust", requireWebAuth, async (req, res) => { +app.post("/trust", requireWebAuth, requireWebRole("admin"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); try { @@ -253,7 +317,8 @@ app.get("/opportunities/:id", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); - return res.type("html").send(renderOpportunity({ opportunity, identity })); + const role = webMediaRole(req); + return res.type("html").send(renderOpportunity({ opportunity, identity, role })); }); app.get("/opportunities", requireWebAuth, async (req, res) => { @@ -264,7 +329,8 @@ app.get("/opportunities", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); - return res.type("html").send(renderOpportunities({ opportunities, identity, status })); + const role = webMediaRole(req); + return res.type("html").send(renderOpportunities({ opportunities, identity, status, role })); }); app.get("/", requireWebAuth, async (req, res) => { @@ -294,9 +360,11 @@ app.get("/", requireWebAuth, async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); + const role = webMediaRole(req); return res.type("html").send(renderDashboard({ identity, + role, counts: counts.rows[0] ?? {}, messages, sources: sourceRows.rows, diff --git a/src/mcp.ts b/src/mcp.ts index 211bb38..d8604e9 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -18,12 +18,14 @@ import { markOpportunity, postAgentMessage, searchEvidence, + setStoryNodeReviewState, sourceStatus, evaluateSourceTrust, listSourceTrustPolicy } from "./db.js"; import { syncAll } from "./sync.js"; import type { SourceId } from "./types.js"; +import { mediaRoleAtLeast, type MediaRole } from "./auth.js"; const tools = [ { @@ -178,7 +180,7 @@ const tools = [ body: { type: "string", maxLength: 8000 }, severity: { type: "string", enum: ["info", "notice", "warning", "error"] }, kind: { type: "string", enum: ["update", "finding", "request", "warning", "error"] }, - related_type: { type: "string", enum: ["opportunity", "source", "evidence", "general"] }, + related_type: { type: "string", enum: ["opportunity", "source", "evidence", "story", "general"] }, related_id: { type: "string" } }, required: ["title", "body"], @@ -251,6 +253,20 @@ const tools = [ additionalProperties: false } }, + { + name: "story_node_review", + description: "Human/editorial review action: mark a Story Thread node active, resolved, or irrelevant. Irrelevant nodes stop contributing to the sourcing audit.", + inputSchema: { + type: "object", + properties: { + thread_id: { type: "string" }, + node_id: { type: "string" }, + review_state: { type: "string", enum: ["active","resolved","irrelevant"] } + }, + required: ["thread_id","node_id","review_state"], + additionalProperties: false + } + }, { name: "story_edge_add", description: "Connect two nodes in a story graph with an explicit evidentiary relationship.", @@ -311,11 +327,32 @@ function actorFromRequest(req: Request) { return { sub: String(auth.sub ?? "unknown"), name: String(auth.preferred_username ?? auth.email ?? auth.name ?? auth.sub ?? "OAuth agent"), - clientId: String(auth.azp ?? auth.client_id ?? process.env.KEYCLOAK_CLIENT_ID ?? "unknown") + clientId: String(auth.azp ?? auth.client_id ?? process.env.KEYCLOAK_CLIENT_ID ?? "unknown"), + role: String(auth.media_role ?? "viewer") as MediaRole }; } +const EDITOR_TOOLS = new Set([ + "sources_sync", + "opportunity_mark", + "evidence_enrich", + "message_post", + "message_acknowledge", + "story_thread_create", + "story_node_add", + "story_node_review", + "story_edge_add", + "story_counter_check_complete" +]); + +function requireToolRole(name: string, role: MediaRole) { + if (EDITOR_TOOLS.has(name) && !mediaRoleAtLeast(role, "editor")) { + throw new Error(`The ${name} action requires the Media Intelligence editor role. Current role: ${role}.`); + } +} + async function callTool(name: string, args: any, actor: ReturnType) { + requireToolRole(name, actor.role); if (name === "sources_list") return result(await listSources()); if (name === "sources_status") return result(await sourceStatus()); if (name === "source_trust_policy") return result(await listSourceTrustPolicy({ enabledOnly: true })); @@ -369,6 +406,12 @@ async function callTool(name: string, args: any, actor: ReturnType = { viewer: 1, editor: 2, admin: 3 }; + return (rank[String(role ?? "viewer")] ?? 1) >= rank[required]; +} + + function shell(args: { title: string; active: "dashboard" | "stories" | "opportunities" | "trust"; identity: string; + role?: string; body: string; }) { const nav = [ @@ -181,6 +188,17 @@ button,input,select{font:inherit} .empty{text-align:center;padding:30px;color:var(--muted)} .toast{position:fixed;right:18px;bottom:18px;background:#172033;color:#fff;border-radius:9px;padding:10px 13px;font-size:.84rem;box-shadow:var(--shadow);opacity:0;transform:translateY(8px);pointer-events:none;transition:.18s}.toast.show{opacity:1;transform:translateY(0)} .mobile-only{display:none} +.research-checklist{display:grid;gap:8px} +.research-check{display:flex;gap:10px;align-items:flex-start;padding:9px 0} +.research-check+.research-check{border-top:1px solid #edf0f4} +.research-check .mark{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;flex:0 0 auto;background:#f1f3f6;color:#7a8494;font-weight:850;font-size:.78rem} +.research-check.done .mark{background:#eaf7f1;color:#116445} +.workbench{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.node-review{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} +.review-irrelevant{opacity:.52} +.review-resolved{border-style:dashed} +.readonly-note{padding:11px 13px;background:#f7f9fb;border:1px solid #e4e9ef;border-radius:9px;color:#5d6879;font-size:.84rem} +@media(max-width:850px){.workbench{grid-template-columns:1fr}} .trust-form{display:grid;grid-template-columns:1.2fr 1.2fr .6fr 1fr 1fr;gap:10px;align-items:end} .trust-form .wide{grid-column:1/-1} .field label{display:block;font-size:.76rem;color:var(--muted);font-weight:700;margin-bottom:5px} @@ -198,7 +216,7 @@ button,input,select{font:inherit}
MIMedia Intelligence -
${esc(initials(args.identity))}
${esc(args.identity)}
Sign out
+
${esc(initials(args.identity))}
${esc(args.identity)} · ${esc(args.role ?? "viewer")}
Sign out
${args.body}
Link copied
@@ -234,7 +252,7 @@ function graphSvg(story: AnyRow) { const p = positions.get(node.id)!; const raw = String(node.title ?? ""); const label = raw.length > 39 ? `${raw.slice(0,36)}…` : raw; - return `${esc(node.title)} — ${esc(node.node_type)}${esc(node.node_type)}${esc(label)}`; + return `${esc(node.title)} — ${esc(node.node_type)}${esc(node.node_type)}${esc(label)}`; }).join(""); return `STORY THREAD${esc(String(story.title).slice(0,42))}${edgeSvg}${nodeSvg}`; @@ -273,14 +291,15 @@ function sourceAuditPanel(story: AnyRow) { `; } -function evidenceCard(node: AnyRow) { +function evidenceCard(node: AnyRow, role = "viewer", storyId?: string) { const kind = node.evidence_kind ?? "discovery_metadata"; const publisher = node.provenance_publisher ?? node.provenance_source_id ?? "Unknown publisher"; const quality = kind === "source_document" ? "Source document" : kind === "source_payload" ? "Official source payload" : "Discovery metadata"; const excerpt = String(node.evidence_excerpt ?? node.summary ?? "").trim(); const sourceDate = node.snapshot_published_at ?? node.snapshot_retrieved_at ?? node.created_at; - return `
-
${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}
${esc(nz(sourceDate))}
+ const state = String(node.review_state ?? "active"); + return `
+
${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}${state!=="active"?badge(state,state==="irrelevant"?"neutral":"green"):""}
${esc(nz(sourceDate))}

${esc(node.title)}

${esc(node.summary)}

${excerpt ? `
${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}
` : ""}
@@ -289,12 +308,17 @@ function evidenceCard(node: AnyRow) {
Snapshot
${esc(node.snapshot_kind ?? "—")}${node.snapshot_id ? ` · ${esc(String(node.snapshot_id).slice(0,12))}` : ""}
Captured
${esc(nz(node.snapshot_retrieved_at))}
- ${node.evidence_url ? `` : ""} +
+ ${node.evidence_url ? `Open original ↗` : ""} + ${storyId && canRole(role,"editor") ? `
` : ""} +
`; } -export function renderStory(args: {story: AnyRow; identity: string}) { + +export function renderStory(args: {story: AnyRow; identity: string; role?: string}) { const {story, identity} = args; + const role = args.role ?? "viewer"; const nodes = Array.isArray(story.nodes) ? story.nodes : []; const edges = Array.isArray(story.edges) ? story.edges : []; const evidenceNodes = nodes.filter((n: AnyRow) => n.evidence_id); @@ -302,10 +326,13 @@ export function renderStory(args: {story: AnyRow; identity: string}) { const researchNodes = nodes.filter((n: AnyRow) => ["question","context","organisation","person","place"].includes(n.node_type)); const byId = new Map(nodes.map((n: AnyRow) => [n.id,n])); const counter = (story.research_checks ?? []).find((c:AnyRow)=>c.check_type==="counter_evidence"); + const checklist = story.research_checklist ?? {items:[],completed:0,total:0,unresolved_questions:[]}; + const requests = Array.isArray(story.research_requests) ? story.research_requests : []; + const editable = canRole(role,"editor"); const body = `
360° Story Thread
-
${badge(story.status,statusTone(story.status))}${story.source_audit?.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${counter ? badge(`counter-evidence: ${counter.result}`,"blue") : badge("counter-evidence pending","amber")}
+
${badge(story.status,statusTone(story.status))}${story.source_audit?.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${counter ? badge(`counter-evidence: ${counter.result}`,"blue") : badge("counter-evidence pending","amber")}${badge(role,"neutral")}

${esc(story.title)}

${esc(story.question)}
Created by ${esc(story.created_by_name)} · updated ${esc(nz(story.updated_at))}
All threads
@@ -313,12 +340,37 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
${sourceAuditPanel(story)}
-

Editorial state

Human-controlled progression. Research gates apply before review/develop.

-
${["researching","review","develop","closed"].map((s) => `
`).join("")}
-

Counter-evidence pass

${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}

-
${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}
+

Research checklist

${compact(checklist.completed)} of ${compact(checklist.total)} checks complete.

+
${(checklist.items??[]).map((item:AnyRow)=>`
${item.done?"✓":"○"}
${esc(item.label)}
`).join("")}
+ ${(checklist.unresolved_questions??[]).length ? `
${checklist.unresolved_questions.length} unresolved research question${checklist.unresolved_questions.length===1?"":"s"}.
` : ""} +
+

Editorial state

Research gates apply before review/develop.

+ ${editable ? `
${["researching","review","develop","closed"].map((st) => `
`).join("")}
` : `
Viewer access: editorial status changes are hidden.
`} +
+

Counter-evidence pass

${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}

+
${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}
+ + ${editable ? `

Research workbench

Add human research context or send a focused request to MCP agents.

+
+
+

Add research item

Claims, questions and editorial context become graph nodes.

+
+
+
+
+
+
+

Request more research

Posts a structured request into Agent Activity for MCP agents.

+
+
+
+
+
` : ""} + + ${requests.length ? `

Research requests

Requests attached to this thread.

${requests.map((r:AnyRow)=>`
${esc(r.title)}
${esc(r.body)}
${badge(r.kind,"blue")}${esc(r.actor_name)}
${esc(nz(r.created_at))}
`).join("")}
` : ""} +

Evidence network

${compact(nodes.length)} nodes · ${compact(edges.length)} relationships

evidenceclaimcounterclaimquestionforecast / trend
Tap a node to jump to its card
${graphSvg(story)}
Graph view is condensed on mobile. Use the evidence and assertion cards below for the full research trail.
@@ -327,13 +379,21 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
${assertions.length ? assertions.map((node: AnyRow) => { const state = assertionState(node, story); const incoming = edges.filter((e:AnyRow)=>e.to_node_id===node.id); - return `
${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}

${esc(node.title)}

${esc(node.summary)}

${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}
`; + const reviewState=String(node.review_state??"active"); + return `
${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}${reviewState!=="active"?badge(reviewState,"neutral"):""}

${esc(node.title)}

${esc(node.summary)}

${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}
+ ${editable ? `
` : ""} +
`; }).join("") : `
No assertions in this thread yet.
`}

Evidence & provenance

Exact publisher, evidence class, snapshot and original source.

-
${evidenceNodes.length ? evidenceNodes.map(evidenceCard).join("") : `
No evidence nodes yet.
`}
+
${evidenceNodes.length ? evidenceNodes.map((node:AnyRow)=>evidenceCard(node,role,story.id)).join("") : `
No evidence nodes yet.
`}
- ${researchNodes.length ? `

Research context

Questions, context and entities that shape the investigation.

${researchNodes.map((n:AnyRow)=>`
${badge(n.node_type,"neutral")}

${esc(n.title)}

${esc(n.summary)}
Added by ${esc(n.created_by_name)}
`).join("")}
` : ""} + ${researchNodes.length ? `

Research context

Questions, context and entities that shape the investigation.

${researchNodes.map((n:AnyRow)=>{ + const reviewState=String(n.review_state??"active"); + return `
${badge(n.node_type,"neutral")}${reviewState!=="active"?badge(reviewState,reviewState==="resolved"?"green":"neutral"):""}

${esc(n.title)}

${esc(n.summary)}
Added by ${esc(n.created_by_name)}
+ ${editable ? `
${n.node_type==="question" ? `
` : ""}
` : ""} +
`; + }).join("")}
` : ""}

Relationships

Human-readable graph edges with rationale.

${edges.length ? edges.map((edge:AnyRow)=>{ @@ -341,37 +401,38 @@ export function renderStory(args: {story: AnyRow; identity: string}) { return `
FROM
${esc(from?.title ?? edge.from_node_id)}
${badge(edge.relationship,"neutral")}
TO
${esc(to?.title ?? edge.to_node_id)}
${esc(edge.reason)}
`; }).join("") : `
No relationships stored.
`}
`; - return shell({title:story.title,active:"stories",identity,body}); + return shell({title:story.title,active:"stories",identity,role,body}); } -export function renderStories(args: {stories: AnyRow[]; identity: string}) { + +export function renderStories(args: {stories: AnyRow[]; identity: string; role?: string}) { const body = `
Research workspace

Story Threads

Evidence networks built around a reporting question, with explicit counter-evidence and sourcing gates before editorial review.

`; - return shell({title:"Story Threads",active:"stories",identity:args.identity,body}); + return shell({title:"Story Threads",active:"stories",identity:args.identity,role:args.role,body}); } -export function renderOpportunity(args:{opportunity:AnyRow;identity:string}) { +export function renderOpportunity(args:{opportunity:AnyRow;identity:string;role?:string}) { const o=args.opportunity, evidence=Array.isArray(o.evidence)?o.evidence:[], notes=Array.isArray(o.notes)?o.notes:[]; const body=`
Opportunity
${badge(o.status,statusTone(o.status))}${badge(`score ${o.score}`,"neutral")}

${esc(o.title)}

${esc(o.summary)}

${esc(o.source_id)} · ${esc(nz(o.published_at || o.detected_at))}
-

Triage

${esc((o.reasons??[]).join(" · "))}

${["watch","investigate","develop","ignore","closed"].map((s)=>`
`).join("")}
+

Triage

${esc((o.reasons??[]).join(" · "))}

${canRole(args.role,"editor")?`
${["watch","investigate","develop","ignore","closed"].map((st)=>`
`).join("")}
`:`
Viewer access: triage controls are hidden.
`}

Why it surfaced

${esc((o.reasons??[]).join(" · ")||"No scoring reasons stored.")}

Stored evidence

${evidence.length} record${evidence.length===1?"":"s"}

${evidence.length?evidence.map((e:AnyRow)=>`
${badge(e.evidence_kind??"stored evidence",evidenceTone(e.evidence_kind))}
${esc(nz(e.retrieved_at))}
${esc(e.excerpt)}
`).join(""):`
No evidence stored.
`}

Notes

${notes.length?notes.map((n:AnyRow)=>`
${esc(nz(n.created_at))}
${esc(n.note)}
`).join(""):`
No notes yet.
`}
`; - return shell({title:o.title,active:"opportunities",identity:args.identity,body}); + return shell({title:o.title,active:"opportunities",identity:args.identity,role:args.role,body}); } -export function renderOpportunities(args:{opportunities:AnyRow[];identity:string;status?:string}) { +export function renderOpportunities(args:{opportunities:AnyRow[];identity:string;status?:string;role?:string}) { const states=["new","watch","investigate","develop","ignore","closed"]; const body=`
Signal triage

Opportunities

Potential stories surfaced from monitored sources. Humans decide what to watch, investigate or develop.

Active${states.map((s)=>`${esc(s)}`).join("")}
`; - return shell({title:"Opportunities",active:"opportunities",identity:args.identity,body}); + return shell({title:"Opportunities",active:"opportunities",identity:args.identity,role:args.role,body}); } -export function renderDashboard(args:{identity:string;counts:AnyRow;messages:AnyRow[];sources:AnyRow[];stories:AnyRow[]}) { +export function renderDashboard(args:{identity:string;role?:string;counts:AnyRow;messages:AnyRow[];sources:AnyRow[];stories:AnyRow[]}) { const c=args.counts??{}, sourceErrors=args.sources.filter((s)=>s.last_error), storyAttention=args.stories.filter((s)=>!s.source_audit?.robust && s.status!=="closed"); const body=`
Newsroom desk

Media Intelligence

Public-source monitoring, evidence research and human editorial control in one shared workspace.

@@ -391,12 +452,12 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any

Agent Activity

Operational findings and source-monitor updates.

-
${args.messages.length?args.messages.map((m:AnyRow)=>`
${esc(m.title)}
${esc(m.body)}
${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}${esc(m.actor_name)} via ${esc(m.actor_client_id)}
${esc(nz(m.created_at))}
`).join(""):`
No unread Agent Activity.
`}
`; - return shell({title:"Desk",active:"dashboard",identity:args.identity,body}); +
${args.messages.length?args.messages.map((m:AnyRow)=>`
${esc(m.title)}
${esc(m.body)}
${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}${esc(m.actor_name)} via ${esc(m.actor_client_id)}
${canRole(args.role,"editor")?`
`:""}
${esc(nz(m.created_at))}
`).join(""):`
No unread Agent Activity.
`}
`; + return shell({title:"Desk",active:"dashboard",identity:args.identity,role:args.role,body}); } -export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) { +export function renderTrustPolicy(args:{identity:string;entries:AnyRow[];role?:string}) { const body=`
Editorial policy

Source Trust

@@ -404,7 +465,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
-
+ ${canRole(args.role,"admin") ? `

Add or update a source

Submitting an existing domain updates its policy entry.

@@ -427,7 +488,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
-
+
` : `
Read-only policy view. Source Trust changes require the admin role.
`}

Current policy

${args.entries.length} configured domains. Agent scoring deduplicates by independence group.

@@ -437,10 +498,10 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
Independence group
${esc(e.independence_group)}
${Number(e.trust_weight).toFixed(2)}
${esc(e.notes||"—")}
- + data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit`:""} `).join("")}
@@ -459,5 +520,5 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) { }); }); `; - return shell({title:"Source Trust",active:"trust",identity:args.identity,body}); + return shell({title:"Source Trust",active:"trust",identity:args.identity,role:args.role,body}); }