Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ build/
data/local/
models/artifacts/
target/

node_modules/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased — documentation and integrity audit

The dashboard now provides a dedicated Publications view with title search, ten-record pagination, and JSON metadata downloads backed by the publication export manifest. Downloads remain bound to the submitted query and displayed record revisions. Unsupported or changed export responses require a refresh; unavailable storage has a retry path. Pending responses cannot overwrite a newer dashboard view or trigger downloads after navigation. Browser coverage verifies actual JSON download contents and failure behavior at desktop and mobile sizes. The server fingerprint is preserved for the documented independent verification procedure.

`GET /api/v1/publications/export/manifest` now exports bounded persisted publication metadata with origin labels, revisions, content hashes, retrieval timestamps, matching totals, and a SHA-256 fingerprint covering the manifest and exported items. Each page and count use one PostgreSQL snapshot; separate page requests remain independent. This metadata inventory includes explicitly labeled synthetic records and does not grant citation eligibility. Tests cover fingerprint verification, metadata changes, database requirements, pagination bounds, and persisted revision/freshness behavior.

`GET /api/v1/evidence/export/citation/schema` now exposes the citation-export contract for clients, including schema version, scoring method, required human-review metadata, known exclusion reasons, manifest fields, and the research disclaimer. This gives frontends and integrations a read-only capability-discovery path before requesting an export.
Expand Down
100 changes: 98 additions & 2 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { matchesPublicationExport } from "./publication-export";

type EvidenceItem = {
identifier: string;
title: string;
Expand Down Expand Up @@ -52,6 +54,8 @@ type PublicationPayload = {
items: PublicationItem[];
total: number;
mode: string;
page: number;
page_size: number;
disclaimer: string;
};

Expand All @@ -74,10 +78,26 @@ const appRoot = document.querySelector<HTMLDivElement>("#app");
if (!appRoot) throw new Error("Dashboard root is missing");
const root = appRoot;

type View = "evidence" | "publications" | "graph" | "sources";
const state = {
topic: "cellular senescence",
view: "evidence" as View,
request: 0,
};

function beginView(view: View): number {
state.view = view;
for (const button of root.querySelectorAll<HTMLButtonElement>("nav button")) {
button.setAttribute("aria-pressed", String(button.dataset.view === view));
}
return ++state.request;
}

function inspectTopic(): void {
if (state.view === "publications") void inspectPublications();
else void inspectEvidence();
}

renderShell();
void updateHealth();
void inspectEvidence();
Expand All @@ -92,6 +112,7 @@ function renderShell(): void {
]),
element("nav", { className: "toolbar", ariaLabel: "Primary dashboard views" }, [
actionButton("Evidence", "evidence", inspectEvidence),
actionButton("Publications", "publications", () => inspectPublications()),
actionButton("Knowledge Graph", "graph", inspectGraph),
actionButton("Sources", "sources", inspectSources),
]),
Expand All @@ -100,7 +121,7 @@ function renderShell(): void {
element("label", { htmlFor: "topic" }, ["Explore a topic"]),
element("div", { className: "query-row" }, [
input("topic", state.topic),
actionButton("Inspect", "inspect", inspectEvidence),
actionButton("Inspect", "inspect", inspectTopic),
]),
element("p", { className: "status", id: "status" }, ["API status: checking..."]),
]),
Expand Down Expand Up @@ -128,9 +149,13 @@ function renderShell(): void {
document.querySelector<HTMLInputElement>("#topic")?.addEventListener("input", (event) => {
state.topic = (event.target as HTMLInputElement).value;
});
document.querySelector<HTMLInputElement>("#topic")?.addEventListener("keydown", (event) => {
if (event.key === "Enter") inspectTopic();
});
}

async function inspectEvidence(): Promise<void> {
const request = beginView("evidence");
const result = resultRegion();
result.replaceChildren(element("p", {}, ["Loading evidence and publication context..."]));
const topic = state.topic.trim();
Expand All @@ -142,6 +167,7 @@ async function inspectEvidence(): Promise<void> {
const evidence = await fetchJson<EvidencePayload>(
`/api/v1/evidence?topic=${encodeURIComponent(topic)}`,
);
if (request !== state.request) return;
if (!evidence.ok) {
result.replaceChildren(offlineNotice("The evidence endpoint is unavailable."));
return;
Expand All @@ -151,6 +177,7 @@ async function inspectEvidence(): Promise<void> {
`/api/v1/search?query=${encodeURIComponent(topic)}&page=1&page_size=10`,
);

if (request !== state.request) return;
const sections: Node[] = [
element("div", { className: "section-heading" }, [
element("div", {}, [
Expand All @@ -177,10 +204,78 @@ async function inspectEvidence(): Promise<void> {
result.replaceChildren(...sections);
}

async function inspectPublications(page = 1, query = state.topic.trim()): Promise<void> {
const request = beginView("publications");
const result = resultRegion();
result.replaceChildren(element("p", {}, ["Searching stored publication titles..."]));
const params = new URLSearchParams({ query, page: String(page), page_size: "10" });
const response = await fetchJson<PublicationPayload>(`/api/v1/search?${params}`);
if (request !== state.request) return;
if (!response.ok) {
result.replaceChildren(
offlineNotice("Publication search is unavailable. Check the API and database connection."),
actionButton("Retry publication search", "retry", () => inspectPublications(page, query)),
);
return;
}
const payload = response.data;
const previous = actionButton("Previous page", "previous", () => inspectPublications(page - 1, query));
previous.disabled = page <= 1;
const next = actionButton("Next page", "next", () => inspectPublications(page + 1, query));
next.disabled = page * 10 >= payload.total || page >= 10000;
const status = element("p", { className: "fineprint export-status", role: "status" });
const download = actionButton("Download page JSON", "export", async () => {
download.disabled = true;
status.textContent = "Preparing publication metadata export...";
try {
const exported = await fetchJson<unknown>(`/api/v1/publications/export/manifest?${params}`);
if (request !== state.request) return;
if (!exported.ok) {
status.textContent = "Export unavailable. Retry the download when the API and database are available.";
return;
}
if (!matchesPublicationExport(exported.data, { query, page, pageSize: 10, total: payload.total, items: payload.items })) {
status.textContent = "Export does not match this page or uses an unsupported format. Refresh publications before downloading.";
return;
}
const url = URL.createObjectURL(new Blob([JSON.stringify(exported.data, null, 2) + "\n"], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `openlongevity-publications-page-${page}.json`;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
status.textContent = `Download started for ${exported.data.total} record(s). SHA-256: ${exported.data.manifest.export_fingerprint}`;
} catch {
if (request === state.request) status.textContent = "Could not prepare the download. Please try again.";
} finally {
download.disabled = payload.items.length === 0;
}
});
download.disabled = payload.items.length === 0;
result.replaceChildren(
element("p", { className: "fineprint" }, [
query ? `Publication title search: “${query}”` : "Browsing all stored publication titles.",
]),
publicationList(payload),
element("div", { className: "toolbar", ariaLabel: "Publication page controls" }, [
previous, element("span", { className: "page-count" }, [`Page ${page} of ${Math.max(1, Math.ceil(payload.total / 10))}`]), next,
actionButton("Refresh publications", "refresh", () => inspectPublications(page, query)), download,
]),
element("p", { className: "fineprint" }, [
"Downloads this page of metadata with revisions, origin labels and a verification fingerprint. Retrieval metadata is fetched when you download. Synthetic records remain labeled; inclusion does not establish scientific validity or citation eligibility.",
]),
status,
);
}

async function inspectGraph(): Promise<void> {
const request = beginView("graph");
const result = resultRegion();
result.replaceChildren(element("p", {}, ["Loading graph demonstration..."]));
const graph = await fetchJson<GraphPayload>("/api/v1/graph");
if (request !== state.request) return;
if (!graph.ok) {
result.replaceChildren(offlineNotice("The graph endpoint is unavailable."));
return;
Expand All @@ -203,6 +298,7 @@ async function inspectGraph(): Promise<void> {
}

function inspectSources(): void {
beginView("sources");
resultRegion().replaceChildren(
element("div", { className: "section-heading" }, [
element("div", {}, [
Expand Down Expand Up @@ -361,7 +457,7 @@ function resultRegion(): HTMLDivElement {

async function fetchJson<T>(path: string): Promise<{ ok: true; data: T } | { ok: false }> {
try {
const response = await fetch(path, { headers: { Accept: "application/json" } });
const response = await fetch(path, { headers: { Accept: "application/json" }, cache: "no-store" });
if (!response.ok) return { ok: false };
return { ok: true, data: await response.json() as T };
} catch {
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/publication-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
type DisplayedPublication = {
identifier: string;
revision: number;
title: string;
origin: string;
synthetic: boolean | null;
};

type ExportRequest = {
query: string;
page: number;
pageSize: number;
total: number;
items: DisplayedPublication[];
};

type PublicationExport = {
schema_version: "publication-export-v1";
mode: "publication-metadata-export";
manifest: Record<string, unknown> & { export_fingerprint: string };
items: Record<string, unknown>[];
total: number;
disclaimer: string;
};

function object(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function hash(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
}

// Validate the supported envelope and displayed records; cryptographic verification
// of the downloaded artifact remains the documented API procedure.
export function matchesPublicationExport(value: unknown, request: ExportRequest): value is PublicationExport {
if (!object(value) || value.schema_version !== "publication-export-v1"
|| value.mode !== "publication-metadata-export" || typeof value.disclaimer !== "string"
|| !object(value.manifest) || !Array.isArray(value.items)) return false;
const manifest = value.manifest;
if (manifest.schema_version !== value.schema_version
|| manifest.source_mode !== "persisted-publications"
|| manifest.query !== request.query || manifest.page !== request.page
|| manifest.page_size !== request.pageSize || manifest.total_matching !== request.total
|| manifest.exported_records !== request.items.length || value.total !== request.items.length
|| value.items.length !== request.items.length || !hash(manifest.export_fingerprint)
|| !Array.isArray(manifest.exported_identifiers)
|| manifest.exported_identifiers.length !== request.items.length
|| !object(manifest.revision_map) || !object(manifest.content_hashes)) return false;
const identifiers = manifest.exported_identifiers;
const revisions = manifest.revision_map;
const hashes = manifest.content_hashes;
if (Object.keys(revisions).length !== request.items.length
|| Object.keys(hashes).length !== request.items.length) return false;
return value.items.every((item: unknown, index: number) => {
if (!object(item)) return false;
const displayed = request.items[index];
return item.identifier === displayed.identifier && item.revision === displayed.revision
&& item.title === displayed.title && item.origin === displayed.origin
&& item.synthetic === displayed.synthetic && identifiers[index] === displayed.identifier
&& Object.hasOwn(revisions, displayed.identifier) && Object.hasOwn(hashes, displayed.identifier)
&& revisions[displayed.identifier] === item.revision
&& hashes[displayed.identifier] === item.content_hash && hash(item.content_hash)
&& typeof item.provider === "string" && typeof item.source_identifier === "string"
&& typeof item.first_retrieved_at === "string" && typeof item.last_retrieved_at === "string";
});
}
23 changes: 23 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,26 @@ h3 {
display: grid;
}
}

button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

nav button[aria-pressed="true"] {
outline: 2px solid #e9fff9;
outline-offset: 2px;
}

.page-count {
align-self: center;
}

.export-status {
overflow-wrap: anywhere;
}

.publication-section {
display: grid;
gap: 1rem;
}
Loading
Loading