Sync role-aware collaboration and Story Thread workbench

This commit is contained in:
Admin 2026-09-29 19:08:24 +00:00
parent 2658cf89cb
commit 7cf1c0123a
5 changed files with 417 additions and 52 deletions

View File

@ -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<MediaRole, number> = {
viewer: 1,
editor: 2,
admin: 3
};
function roleNamesFromClaims(claims: Record<string, any>) {
const roles = new Set<string>();
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<string, any>)) {
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<string, any>): 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<string, any>;
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<string, any>;
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<string, any>),
role_names: roleNamesFromClaims(payload as Record<string, any>)
};
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<string, any> | null = null;
if (tokens.access_token) {
try {
const verified = await jwtVerify(tokens.access_token, webJwks, {
issuer: cfg.issuer
});
accessPayload = verified.payload as Record<string, any>;
} catch {
accessPayload = null;
}
}
const idClaims = payload as Record<string, any>;
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));

102
src/db.ts
View File

@ -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");

View File

@ -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, "&amp;")
@ -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,

View File

@ -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<typeof actorFromRequest>) {
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<typeof actorF
messageId: args.message_id,
confidence: args.confidence
}, actor));
if (name === "story_node_review") return result(await setStoryNodeReviewState(
args.thread_id,
args.node_id,
args.review_state,
actor
));
if (name === "story_edge_add") return result(await addStoryEdge(args.thread_id, {
fromNodeId: args.from_node_id,
toNodeId: args.to_node_id,

View File

@ -54,10 +54,17 @@ function evidenceTone(kind: unknown) {
return "neutral";
}
function canRole(role: string | undefined, required: "viewer" | "editor" | "admin") {
const rank: Record<string, number> = { 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}
<div class="topbar"><div class="topbar-inner">
<a class="brand" href="/"><span class="brand-mark">MI</span><span>Media Intelligence</span></a>
<nav class="nav">${nav.map(([key, href, label]) => `<a href="${href}" class="${args.active === key ? "active" : ""}">${label}</a>`).join("")}</nav>
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)}</div><a class="signout" href="/auth/logout">Sign out</a></div></div>
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)} · ${esc(args.role ?? "viewer")}</div><a class="signout" href="/auth/logout">Sign out</a></div></div>
</div></div>
<main class="page">${args.body}</main>
<div id="toast" class="toast">Link copied</div>
@ -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 `<a href="#node-${encodeURIComponent(node.id)}"><g class="graph-node ${esc(node.node_type)}"><title>${esc(node.title)} — ${esc(node.node_type)}</title><rect x="${p.x-108}" y="${p.y-39}" width="216" height="78" rx="13"></rect><text class="node-type" x="${p.x}" y="${p.y-12}" text-anchor="middle">${esc(node.node_type)}</text><text class="node-title" x="${p.x}" y="${p.y+11}" text-anchor="middle">${esc(label)}</text></g></a>`;
return `<a href="#node-${encodeURIComponent(node.id)}"><g class="graph-node ${esc(node.node_type)} review-${esc(node.review_state ?? "active")}"><title>${esc(node.title)} — ${esc(node.node_type)}</title><rect x="${p.x-108}" y="${p.y-39}" width="216" height="78" rx="13"></rect><text class="node-type" x="${p.x}" y="${p.y-12}" text-anchor="middle">${esc(node.node_type)}</text><text class="node-title" x="${p.x}" y="${p.y+11}" text-anchor="middle">${esc(label)}</text></g></a>`;
}).join("");
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Research graph for ${esc(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-135}" y="${cy-49}" width="270" height="98" rx="17"></rect><text x="${cx}" y="${cy-10}" text-anchor="middle">STORY THREAD</text><text x="${cx}" y="${cy+18}" text-anchor="middle">${esc(String(story.title).slice(0,42))}</text></g>${edgeSvg}${nodeSvg}</svg>`;
@ -273,14 +291,15 @@ function sourceAuditPanel(story: AnyRow) {
</div>`;
}
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 `<article class="panel evidence-card" id="node-${esc(node.id)}">
<div class="evidence-head"><div>${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}</div><div class="meta">${esc(nz(sourceDate))}</div></div>
const state = String(node.review_state ?? "active");
return `<article class="panel evidence-card review-${esc(state)}" id="node-${esc(node.id)}">
<div class="evidence-head"><div>${badge(quality,evidenceTone(kind))}${node.provenance_authority_tier ? badge(`Tier ${node.provenance_authority_tier}`,"neutral") : ""}${state!=="active"?badge(state,state==="irrelevant"?"neutral":"green"):""}</div><div class="meta">${esc(nz(sourceDate))}</div></div>
<h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p>
${excerpt ? `<div class="evidence-excerpt">${esc(excerpt.slice(0,1500))}${excerpt.length > 1500 ? "…" : ""}</div>` : ""}
<dl class="provenance">
@ -289,12 +308,17 @@ function evidenceCard(node: AnyRow) {
<dt>Snapshot</dt><dd>${esc(node.snapshot_kind ?? "—")}${node.snapshot_id ? ` · <span class="mono">${esc(String(node.snapshot_id).slice(0,12))}</span>` : ""}</dd>
<dt>Captured</dt><dd>${esc(nz(node.snapshot_retrieved_at))}</dd>
</dl>
${node.evidence_url ? `<div style="margin-top:12px"><a class="btn btn-sm" href="${esc(node.evidence_url)}" target="_blank" rel="noreferrer">Open original ↗</a></div>` : ""}
<div style="margin-top:12px" class="actions">
${node.evidence_url ? `<a class="btn btn-sm" href="${esc(node.evidence_url)}" target="_blank" rel="noreferrer">Open original ↗</a>` : ""}
${storyId && canRole(role,"editor") ? `<form method="post" action="/stories/${encodeURIComponent(storyId)}/nodes/${encodeURIComponent(node.id)}/review"><input type="hidden" name="review_state" value="${state==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${state==="irrelevant"?"Restore evidence":"Mark irrelevant"}</button></form>` : ""}
</div>
</article>`;
}
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 = `
<div class="thread-hero"><div><div class="eyebrow">360° Story Thread</div>
<div class="row-meta">${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")}</div>
<div class="row-meta">${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")}</div>
<h1 class="thread-title">${esc(story.title)}</h1><div class="thread-question">${esc(story.question)}</div>
<div class="meta" style="margin-top:11px">Created by ${esc(story.created_by_name)} · updated ${esc(nz(story.updated_at))}</div></div>
<div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="/stories">All threads</a></div></div>
@ -313,11 +340,36 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
<div class="section">${sourceAuditPanel(story)}</div>
<div class="section grid grid-2">
<div class="panel"><div class="section-head"><div><h2>Editorial state</h2><p>Human-controlled progression. Research gates apply before review/develop.</p></div></div>
<div class="status-actions">${["researching","review","develop","closed"].map((s) => `<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button class="btn btn-sm ${story.status===s ? "btn-primary" : ""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
<div class="panel ${counter ? "callout success" : "callout warning"}"><div class="section-head"><div><h2>Counter-evidence pass</h2><p>${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}</p></div></div>
<div style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
<div class="panel"><div class="section-head"><div><h2>Research checklist</h2><p>${compact(checklist.completed)} of ${compact(checklist.total)} checks complete.</p></div></div>
<div class="research-checklist">${(checklist.items??[]).map((item:AnyRow)=>`<div class="research-check ${item.done?"done":""}"><div class="mark">${item.done?"✓":"○"}</div><div><strong>${esc(item.label)}</strong></div></div>`).join("")}</div>
${(checklist.unresolved_questions??[]).length ? `<div class="meta" style="margin-top:10px">${checklist.unresolved_questions.length} unresolved research question${checklist.unresolved_questions.length===1?"":"s"}.</div>` : ""}
</div>
<div class="panel"><div class="section-head"><div><h2>Editorial state</h2><p>Research gates apply before review/develop.</p></div></div>
${editable ? `<div class="status-actions">${["researching","review","develop","closed"].map((st) => `<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${st}"><button class="btn btn-sm ${story.status===st ? "btn-primary" : ""}" type="submit">${esc(st)}</button></form>`).join("")}</div>` : `<div class="readonly-note">Viewer access: editorial status changes are hidden.</div>`}
</div>
</div>
<div class="section panel ${counter ? "callout success" : "callout warning"}"><div class="section-head"><div><h2>Counter-evidence pass</h2><p>${counter ? `${esc(counter.result)} · ${esc((counter.sources_checked ?? []).join(", "))}` : "Required before editorial progression"}</p></div></div>
<div style="font-size:.87rem;color:#536075">${counter ? esc(counter.summary) : "No completed counter-evidence check is stored for this thread."}</div></div>
${editable ? `<section class="section"><div class="section-head"><div><h2>Research workbench</h2><p>Add human research context or send a focused request to MCP agents.</p></div></div>
<div class="workbench">
<form class="panel" method="post" action="/stories/${encodeURIComponent(story.id)}/nodes">
<div class="section-head"><div><h2>Add research item</h2><p>Claims, questions and editorial context become graph nodes.</p></div></div>
<div class="field"><label>Type</label><select name="node_type"><option value="question">question</option><option value="context">editorial note / context</option><option value="claim">claim</option><option value="counterclaim">counterclaim</option><option value="trend">trend</option><option value="forecast">forecast</option></select></div>
<div class="field" style="margin-top:9px"><label>Title</label><input name="title" required maxlength="240"></div>
<div class="field" style="margin-top:9px"><label>Detail</label><textarea name="summary" maxlength="8000"></textarea></div>
<div class="actions" style="margin-top:10px"><button class="btn btn-primary" type="submit">Add to thread</button></div>
</form>
<form class="panel" method="post" action="/stories/${encodeURIComponent(story.id)}/request-research">
<div class="section-head"><div><h2>Request more research</h2><p>Posts a structured request into Agent Activity for MCP agents.</p></div></div>
<div class="field"><label>Request</label><input name="title" placeholder="e.g. Find independent insolvency trend data" required maxlength="150"></div>
<div class="field" style="margin-top:9px"><label>What should be checked?</label><textarea name="body" required maxlength="8000"></textarea></div>
<div class="actions" style="margin-top:10px"><button class="btn btn-primary" type="submit">Send research request</button></div>
</form>
</div></section>` : ""}
${requests.length ? `<section class="section"><div class="section-head"><div><h2>Research requests</h2><p>Requests attached to this thread.</p></div></div><div class="panel">${requests.map((r:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(r.title)}</div><div class="row-sub">${esc(r.body)}</div><div class="row-meta">${badge(r.kind,"blue")}<span class="meta">${esc(r.actor_name)}</span></div></div><div class="right-meta meta">${esc(nz(r.created_at))}</div></div></div>`).join("")}</div></section>` : ""}
<section class="section"><div class="section-head"><div><h2>Evidence network</h2><p>${compact(nodes.length)} nodes · ${compact(edges.length)} relationships</p></div></div>
<div class="graph-shell"><div class="graph-toolbar"><div class="legend"><span class="evidence">evidence</span><span class="claim">claim</span><span class="counter">counterclaim</span><span class="question">question</span><span class="forecast">forecast / trend</span></div><div class="meta">Tap a node to jump to its card</div></div>
@ -327,13 +379,21 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
<div class="assertion-grid">${assertions.length ? assertions.map((node: AnyRow) => {
const state = assertionState(node, story);
const incoming = edges.filter((e:AnyRow)=>e.to_node_id===node.id);
return `<article class="panel assertion ${state}" id="node-${esc(node.id)}"><div class="row-meta">${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}</div><h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p><div class="meta">${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}</div></article>`;
const reviewState=String(node.review_state??"active");
return `<article class="panel assertion ${state} review-${esc(reviewState)}" id="node-${esc(node.id)}"><div class="row-meta">${badge(node.node_type,"neutral")}${badge(state,state==="supported"?"green":state==="contradicted"?"red":state==="qualified"?"amber":"neutral")}${reviewState!=="active"?badge(reviewState,"neutral"):""}</div><h3>${esc(node.title)}</h3><p>${esc(node.summary)}</p><div class="meta">${incoming.length ? `${incoming.length} incoming relationship${incoming.length===1?"":"s"}` : "No evidence relationship yet"}${node.confidence != null ? ` · confidence ${esc(node.confidence)}` : ""}</div>
${editable ? `<div class="node-review"><form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(node.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${reviewState==="irrelevant"?"Restore":"Mark irrelevant"}</button></form></div>` : ""}
</article>`;
}).join("") : `<div class="panel empty">No assertions in this thread yet.</div>`}</div></section>
<section class="section"><div class="section-head"><div><h2>Evidence & provenance</h2><p>Exact publisher, evidence class, snapshot and original source.</p></div></div>
<div class="evidence-grid">${evidenceNodes.length ? evidenceNodes.map(evidenceCard).join("") : `<div class="panel empty">No evidence nodes yet.</div>`}</div></section>
<div class="evidence-grid">${evidenceNodes.length ? evidenceNodes.map((node:AnyRow)=>evidenceCard(node,role,story.id)).join("") : `<div class="panel empty">No evidence nodes yet.</div>`}</div></section>
${researchNodes.length ? `<section class="section"><div class="section-head"><div><h2>Research context</h2><p>Questions, context and entities that shape the investigation.</p></div></div><div class="grid grid-cards">${researchNodes.map((n:AnyRow)=>`<article class="panel" id="node-${esc(n.id)}"><div class="row-meta">${badge(n.node_type,"neutral")}</div><h3 style="margin:8px 0 5px;font-size:.98rem">${esc(n.title)}</h3><div class="muted" style="font-size:.87rem">${esc(n.summary)}</div><div class="meta" style="margin-top:10px">Added by ${esc(n.created_by_name)}</div></article>`).join("")}</div></section>` : ""}
${researchNodes.length ? `<section class="section"><div class="section-head"><div><h2>Research context</h2><p>Questions, context and entities that shape the investigation.</p></div></div><div class="grid grid-cards">${researchNodes.map((n:AnyRow)=>{
const reviewState=String(n.review_state??"active");
return `<article class="panel review-${esc(reviewState)}" id="node-${esc(n.id)}"><div class="row-meta">${badge(n.node_type,"neutral")}${reviewState!=="active"?badge(reviewState,reviewState==="resolved"?"green":"neutral"):""}</div><h3 style="margin:8px 0 5px;font-size:.98rem">${esc(n.title)}</h3><div class="muted" style="font-size:.87rem">${esc(n.summary)}</div><div class="meta" style="margin-top:10px">Added by ${esc(n.created_by_name)}</div>
${editable ? `<div class="node-review">${n.node_type==="question" ? `<form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(n.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="resolved"?"active":"resolved"}"><button class="btn btn-sm" type="submit">${reviewState==="resolved"?"Reopen":"Resolve question"}</button></form>` : ""}<form method="post" action="/stories/${encodeURIComponent(story.id)}/nodes/${encodeURIComponent(n.id)}/review"><input type="hidden" name="review_state" value="${reviewState==="irrelevant"?"active":"irrelevant"}"><button class="btn btn-sm" type="submit">${reviewState==="irrelevant"?"Restore":"Mark irrelevant"}</button></form></div>` : ""}
</article>`;
}).join("")}</div></section>` : ""}
<section class="section"><div class="section-head"><div><h2>Relationships</h2><p>Human-readable graph edges with rationale.</p></div></div>
<div class="panel">${edges.length ? edges.map((edge:AnyRow)=>{
@ -341,37 +401,38 @@ export function renderStory(args: {story: AnyRow; identity: string}) {
return `<div class="relationship"><div><div class="meta">FROM</div><div class="relationship-title">${esc(from?.title ?? edge.from_node_id)}</div></div><div class="relationship-mid">${badge(edge.relationship,"neutral")}</div><div><div class="meta">TO</div><div class="relationship-title">${esc(to?.title ?? edge.to_node_id)}</div><div class="meta" style="margin-top:4px">${esc(edge.reason)}</div></div></div>`;
}).join("") : `<div class="empty">No relationships stored.</div>`}</div></section>`;
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 = `<div class="page-head"><div><div class="eyebrow">Research workspace</div><h1>Story Threads</h1><p>Evidence networks built around a reporting question, with explicit counter-evidence and sourcing gates before editorial review.</p></div></div>
<div class="panel">${args.stories.length ? args.stories.map((s:AnyRow)=>{
const audit=s.source_audit ?? {};
return `<a class="story-row" href="/stories/${encodeURIComponent(s.id)}"><div class="list-row"><div><div class="row-title">${esc(s.title)}</div><div class="row-sub">${esc(s.question)}</div><div class="row-meta">${badge(s.status,statusTone(s.status))}${audit.robust ? badge("sourcing ready","green") : badge("sourcing gaps","amber")}${s.counter_evidence_result ? badge(`counter: ${s.counter_evidence_result}`,"blue") : badge("counter pending","neutral")}</div></div><div class="right-meta meta">${compact(s.nodes?.length ?? s.node_count)} nodes<br>${compact(s.edges?.length ?? s.edge_count)} edges<br>updated ${esc(nz(s.updated_at))}</div></div></a>`;
}).join("") : `<div class="empty">No Story Threads yet.</div>`}</div>`;
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=`<div class="page-head"><div><div class="eyebrow">Opportunity</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(`score ${o.score}`,"neutral")}</div><h1 style="margin-top:9px">${esc(o.title)}</h1><p>${esc(o.summary)}</p><div class="meta">${esc(o.source_id)} · ${esc(nz(o.published_at || o.detected_at))}</div></div><div class="actions"><button class="btn" type="button" onclick="copyCurrentLink()">Copy link</button><a class="btn" href="${esc(o.url)}" target="_blank" rel="noreferrer">Original source ↗</a></div></div>
<div class="grid grid-2"><div class="panel"><div class="section-head"><div><h2>Triage</h2><p>${esc((o.reasons??[]).join(" · "))}</p></div></div><div class="status-actions">${["watch","investigate","develop","ignore","closed"].map((s)=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><input type="hidden" name="returnTo" value="detail"><button class="btn btn-sm ${o.status===s?"btn-primary":""}" type="submit">${esc(s)}</button></form>`).join("")}</div></div>
<div class="grid grid-2"><div class="panel"><div class="section-head"><div><h2>Triage</h2><p>${esc((o.reasons??[]).join(" · "))}</p></div></div>${canRole(args.role,"editor")?`<div class="status-actions">${["watch","investigate","develop","ignore","closed"].map((st)=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${st}"><input type="hidden" name="returnTo" value="detail"><button class="btn btn-sm ${o.status===st?"btn-primary":""}" type="submit">${esc(st)}</button></form>`).join("")}</div>`:`<div class="readonly-note">Viewer access: triage controls are hidden.</div>`}</div>
<div class="panel"><div class="section-head"><div><h2>Why it surfaced</h2></div></div><div class="muted" style="font-size:.88rem">${esc((o.reasons??[]).join(" · ")||"No scoring reasons stored.")}</div></div></div>
<section class="section"><div class="section-head"><div><h2>Stored evidence</h2><p>${evidence.length} record${evidence.length===1?"":"s"}</p></div></div><div class="evidence-grid">${evidence.length?evidence.map((e:AnyRow)=>`<article class="panel evidence-card"><div class="evidence-head"><div>${badge(e.evidence_kind??"stored evidence",evidenceTone(e.evidence_kind))}</div><div class="meta">${esc(nz(e.retrieved_at))}</div></div><div class="evidence-excerpt" style="margin-top:10px">${esc(e.excerpt)}</div><div style="margin-top:10px"><a class="btn btn-sm" href="${esc(e.source_url)}" target="_blank" rel="noreferrer">Open source ↗</a></div></article>`).join(""):`<div class="panel empty">No evidence stored.</div>`}</div></section>
<section class="section"><div class="section-head"><div><h2>Notes</h2></div></div>${notes.length?notes.map((n:AnyRow)=>`<div class="panel"><div class="meta">${esc(nz(n.created_at))}</div><div style="margin-top:5px">${esc(n.note)}</div></div>`).join(""):`<div class="panel empty">No notes yet.</div>`}</section>`;
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=`<div class="page-head"><div><div class="eyebrow">Signal triage</div><h1>Opportunities</h1><p>Potential stories surfaced from monitored sources. Humans decide what to watch, investigate or develop.</p></div></div>
<div class="filterbar"><a class="${!args.status?"active":""}" href="/opportunities">Active</a>${states.map((s)=>`<a class="${args.status===s?"active":""}" href="/opportunities?status=${s}">${esc(s)}</a>`).join("")}</div>
<div class="panel">${args.opportunities.length?args.opportunities.map((o:AnyRow)=>`<a class="opportunity-row" href="/opportunities/${encodeURIComponent(o.id)}"><div class="list-row"><div><div class="row-title">${esc(o.title)}</div><div class="row-sub">${esc(o.summary)}</div><div class="row-meta">${badge(o.status,statusTone(o.status))}${badge(o.source_id,"neutral")}<span class="meta">${esc((o.reasons??[]).join(" · "))}</span></div></div><div class="right-meta meta">score ${esc(o.score)}<br>${esc(nz(o.published_at||o.detected_at))}</div></div></a>`).join(""):`<div class="empty">No opportunities in this view.</div>`}</div>`;
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=`<div class="page-head"><div><div class="eyebrow">Newsroom desk</div><h1>Media Intelligence</h1><p>Public-source monitoring, evidence research and human editorial control in one shared workspace.</p></div><div class="actions"><a class="btn btn-primary" href="/stories">Open research desk</a></div></div>
<div class="grid grid-metrics">
@ -391,12 +452,12 @@ export function renderDashboard(args:{identity:string;counts:AnyRow;messages:Any
</div>
</div>
<section class="section"><div class="section-head"><div><h2>Agent Activity</h2><p>Operational findings and source-monitor updates.</p></div></div>
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div><form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form></div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
return shell({title:"Desk",active:"dashboard",identity:args.identity,body});
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div>${canRole(args.role,"editor")?`<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form>`:""}</div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
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=`
<div class="page-head">
<div><div class="eyebrow">Editorial policy</div><h1>Source Trust</h1>
@ -404,7 +465,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
</div>
</div>
<div class="panel">
${canRole(args.role,"admin") ? ` <div class="panel">
<div class="section-head"><div><h2>Add or update a source</h2><p>Submitting an existing domain updates its policy entry.</p></div></div>
<form class="trust-form" method="post" action="/trust">
<div class="field"><label>Domain</label><input id="trust-domain" name="domain" placeholder="example.org" required></div>
@ -427,7 +488,7 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
<div class="field"><label>Enabled</label><select id="trust-enabled" name="enabled"><option value="true">enabled</option><option value="false">disabled</option></select></div>
<div class="actions"><button class="btn btn-primary" type="submit">Save source</button></div>
</form>
</div>
</div>` : `<div class="panel readonly-note"><strong>Read-only policy view.</strong> Source Trust changes require the admin role.</div>`}
<section class="section">
<div class="section-head"><div><h2>Current policy</h2><p>${args.entries.length} configured domains. Agent scoring deduplicates by independence group.</p></div></div>
@ -437,10 +498,10 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
<div class="optional-col"><div class="meta">Independence group</div><div>${esc(e.independence_group)}</div></div>
<div><div class="weight">${Number(e.trust_weight).toFixed(2)}</div><div class="weightbar"><span style="width:${Math.round(Number(e.trust_weight)*100)}%"></span></div></div>
<div class="optional-col meta">${esc(e.notes||"—")}</div>
<button class="btn btn-sm" type="button" data-edit-trust
${canRole(args.role,"admin")?`<button class="btn btn-sm" type="button" data-edit-trust
data-domain="${esc(e.domain)}" data-name="${esc(e.name)}" data-weight="${esc(e.trust_weight)}"
data-group="${esc(e.independence_group)}" data-class="${esc(e.source_class)}"
data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit</button>
data-notes="${esc(e.notes)}" data-enabled="${e.enabled?"true":"false"}">Edit</button>`:""}
</div>`).join("")}
</div>
</section>
@ -459,5 +520,5 @@ export function renderTrustPolicy(args:{identity:string;entries:AnyRow[]}) {
});
});
</script>`;
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});
}