disclosure-bureau/web/app/d/[docId]/page.tsx
Luiz Gustavo 55cac8a395
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 1m30s
CI / Scripts — Python smoke (push) Failing after 32s
CI / Web — npm audit (push) Failing after 37s
W0+W1+W1.2: security hardening, observability, autocomplete, glitchtip, forgejo CI
W0 — security hardening (5 fixes verified live on disclosure.top)
- middleware: gate /api/admin/* same as /admin/* (F1)
- imgproxy: tighten LOCAL_FILESYSTEM_ROOT from / to /var/lib/storage (F2)
- studio: real basic-auth label (bcrypt hash, middleware reference) (F3)
- relations: ENABLE ROW LEVEL SECURITY + public SELECT policy (F4)
- migration 0003: fold is_searchable + hybrid_search update into canonical (TD#2)

W1 — observability + resilience + autocomplete
- studio: HOSTNAME=0.0.0.0 so Next.js binds on loopback for healthcheck
- compose: PG_POOL_MAX=20, CLAUDE_CODE_OAUTH_TOKEN gated by separate env
- claude-code.ts: subprocess timeout configurable (CLAUDE_CODE_TIMEOUT_MS)
- openrouter.ts: retry with exponential backoff + Retry-After + in-memory
  circuit breaker (promotes FALLBACK after CB_THRESHOLD failures)
- lib/logger.ts: pino logger (NDJSON prod / pretty dev) + withRequest helper
- middleware: mints correlation_id, stamps x-correlation-id response header,
  emits structured http_request log per /api/* call
- messages/route.ts: switch to structured logger
- 60_meili_index.py: push documents + chunks into Meilisearch
- /api/search/autocomplete: parallel meili search (docs + chunks), 5-8ms p50
- search-autocomplete.tsx: debounced dropdown wired into search-panel

W1.2 — Glitchtip + Forgejo self-hosted
- compose: glitchtip-redis + glitchtip-web + glitchtip-worker (v4.2)
- compose: forgejo + forgejo-runner (server v9, runner v6) with group_add=988
- @sentry/nextjs SDK wired (instrumentation.ts + sentry.{client,server}.config.ts)
- /api/admin/throw smoke endpoint (gated by W0-F1 middleware)
- Synthetic event ingestion verified at glitchtip.disclosure.top
- forgejo.disclosure.top up, repo discadmin/disclosure-bureau created,
  runner registered (labels: ubuntu-latest, docker)
- .forgejo/workflows/ci.yml: typecheck + lint + build + npm audit + python
  syntax + compose validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:18:42 -03:00

161 lines
6 KiB
TypeScript

/**
* /d/<docId> — Document view (Sonnet 4.6 agentic chunks v0.2.0, the only view).
*
* Server component loads chunks from disk; client component <DocRendererV2>
* provides language + flow/paged toggles, image crops on-demand, table CSVs.
*/
import Link from "next/link";
import { notFound } from "next/navigation";
import { readChunksByPage, readIndex, hasChunks, readReadingVersion } from "@/lib/chunks";
import { readDocument } from "@/lib/wiki";
import { pickPitch } from "@/lib/doc-summary";
import { getLocale } from "@/components/locale-toggle";
import { AuthBar } from "@/components/auth-bar";
import { ChatBubble } from "@/components/chat-bubble";
import { DocReadingView } from "@/components/doc-reading-view";
import { AnomalyHighlights, type AnomalyFlag } from "@/components/anomaly-highlights";
import { MarkdownBody } from "@/components/markdown-body";
export const dynamic = "force-dynamic";
export default async function DocPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const { docId } = await params;
const locale = await getLocale();
if (!(await hasChunks(docId))) {
return (
<main className="min-h-screen p-6 md:p-10 max-w-4xl mx-auto">
<div className="flex items-start justify-between gap-4 mb-6">
<Link href="/" className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
home
</Link>
<AuthBar />
</div>
<div className="border border-[rgba(255,165,0,0.30)] bg-[rgba(255,165,0,0.05)] rounded p-6">
<h1 className="font-mono text-lg text-[#ffa500] mb-2"> Documento ainda não processado</h1>
<p className="text-[#c8d4e6] text-sm">
Este documento ainda não foi indexado.
</p>
<p className="font-mono text-xs text-[#5a6678] mt-4">doc_id: {docId}</p>
</div>
</main>
);
}
const [idx, byPage, doc, reading] = await Promise.all([
readIndex(docId),
readChunksByPage(docId),
readDocument(docId),
readReadingVersion(docId),
]);
if (!idx) notFound();
const ordered: Array<[number, typeof byPage extends Map<number, infer V> ? V : never]> =
Array.from(byPage.entries()).sort((a, b) => a[0] - b[0]);
const pitch = pickPitch(
doc?.fm as Record<string, unknown> | undefined,
locale === "en" ? "en" : "pt",
);
// Histogram chunk types
const typeCounts = new Map<string, number>();
for (const entry of idx.chunks ?? []) {
typeCounts.set(entry.type, (typeCounts.get(entry.type) || 0) + 1);
}
const topTypes = Array.from(typeCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 6);
// Count UFO/cryptid anomalies across chunks + collect flags for the highlight panel
let imageCount = 0;
const ufoFlags: AnomalyFlag[] = [];
const cryptidFlags: AnomalyFlag[] = [];
for (const [page, chunks] of byPage) {
for (const c of chunks) {
if (c.fm.ufo_anomaly_detected)
ufoFlags.push({
chunk_id: c.fm.chunk_id,
page,
type: c.fm.ufo_anomaly_type ?? null,
rationale: c.fm.ufo_anomaly_rationale ?? null,
});
if (c.fm.cryptid_anomaly_detected)
cryptidFlags.push({
chunk_id: c.fm.chunk_id,
page,
type: c.fm.cryptid_anomaly_type ?? null,
rationale: c.fm.cryptid_anomaly_rationale ?? null,
});
if (c.fm.type === "image") imageCount++;
}
}
const ufoCount = ufoFlags.length;
const cryptidCount = cryptidFlags.length;
const classification = (doc?.fm.highest_classification as string) ?? "—";
const collection = (doc?.fm.collection as string) ?? "—";
return (
<main className="min-h-screen p-6 md:p-10 max-w-5xl mx-auto">
<div className="flex items-start justify-between gap-4 mb-6">
<Link href="/" className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
home
</Link>
<AuthBar />
</div>
<header className="mb-6 pb-6 border-b border-[rgba(0,255,156,0.32)]">
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2 flex items-center gap-2 flex-wrap">
<span className="px-2 py-0.5 border border-[#ff3344] text-[#ff3344] rounded">
{classification}
</span>
<span>· {collection}</span>
<span>· doc_id: <span className="text-[#7fdbff]">{docId}</span></span>
</div>
<h1 className="font-mono text-3xl text-[#00ff9c] mb-3">
{(doc?.fm.canonical_title as string) ?? docId}
</h1>
{pitch && (
<div className="mt-4 mb-4 p-4 border-l-4 border-[#7fdbff] bg-[rgba(127,219,255,0.04)]">
<div className="text-[15px] leading-relaxed text-[#c8d4e6] markdown-body">
<MarkdownBody>{pitch}</MarkdownBody>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-4 mt-4 font-mono text-xs text-[#8896aa]">
<span><span className="text-[#7fdbff]">{idx.total_pages}</span> páginas</span>
<span><span className="text-[#00ff9c]">{idx.total_chunks}</span> trechos</span>
{imageCount > 0 && <span><span className="text-[#a78bfa]">{imageCount}</span> imagens</span>}
{ufoCount > 0 && <span><span className="text-[#ff3344]">🛸 {ufoCount}</span> UAP flags</span>}
{cryptidCount > 0 && <span><span className="text-[#9b5de5]">{cryptidCount}</span> cryptid</span>}
</div>
{topTypes.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5 font-mono text-[10px]">
{topTypes.map(([t, n]) => (
<span
key={t}
className="px-2 py-0.5 border border-[rgba(127,219,255,0.20)] text-[#8896aa] rounded"
>
{t} <span className="text-[#7fdbff]">{n}</span>
</span>
))}
</div>
)}
</header>
<AnomalyHighlights docId={docId} ufo={ufoFlags} cryptid={cryptidFlags} />
<DocReadingView docId={docId} reading={reading} chunksByPage={ordered} />
<ChatBubble context={{ doc_id: docId }} />
</main>
);
}