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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ changing first. The parts, and the one place each rule lives:
- **Read through `throughCache(cached, direct)`.** A production build replaces the message of
anything thrown inside a cached function, so when the cached call throws the direct one runs
and reports the real error. It also covers a cache that cannot be read.
- **An administrator clears it, and changes federation settings, at `/admin`** (the users in
`ADMIN_EMAILS`; account menu → Admin). `lib/settings.ts`: an `app_setting` row overrides the
environment variable of the same name for every process within seconds; federation code reads
those settings through `setting(name)`, never `process.env`. `cacheVersion()` (data version plus
the settings' fingerprint, which includes the last clear) is every cached entry's key argument —
cohort locks keep `dataVersion()` alone. Database addresses and credentials are never editable.
- **`RESULT_CACHE=off` turns it off.** `throughCache` then runs the direct call on every
request, so a server answers everything cold — for measuring what a first reader waits for.
Without a cache handler configured (anywhere but Vercel), the cache is the Node process's own
Expand Down
5 changes: 5 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const metadata = { title: "Admin — cbio-lab" };

export default function AdminLayout({ children }: { children: React.ReactNode }) {
return children;
}
193 changes: 193 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"use client";
// /admin — for the users in ADMIN_EMAILS (lib/auth.ts), reached from the account menu: clear the
// result cache, and see and change the federation settings for the whole deployment
// (lib/settings.ts, app/api/admin/route.ts). A change applies to every server process within a few
// seconds and gives every cached result a new key, so nothing computed under the old settings is
// served under the new ones.
import { useCallback, useEffect, useState } from "react";
import { Loader2, RotateCcw, Trash2 } from "lucide-react";
import { SiteHeader } from "../_ui/site-header.tsx";
import { Button } from "../_ui/button";
import { Input } from "../_ui/input";
import { ErrorAlert } from "../_ui/error-alert.tsx";
import { TB } from "../_ui/toolbar.ts";
import type { SettingGroup, SettingRow, SettingsReport } from "../../lib/settings.ts";

const GROUPS: { key: SettingGroup; title: string; about: string }[] = [
{ key: "databases", title: "Databases", about: "The warehouses this instance queries directly. Their addresses and credentials are read from the environment and cannot be changed here." },
{ key: "peers", title: "Peers over MCP", about: "Studies this instance reaches through another cbio-lab instance's /api/mcp, which answers with aggregates only." },
{ key: "patient-level", title: "Patient-level federation", about: "The opt-in exception to aggregate-only federation (docs/DATA-BOUNDARY.md): data crosses only where the peer discloses a kind and the coordinator accepts it." },
{ key: "access", title: "Access", about: "Which studies are routed at all, and which are treated as aggregate-only." },
];

async function call(init?: RequestInit): Promise<SettingsReport> {
const r = await fetch("/api/admin", init && { ...init, headers: { "content-type": "application/json" } });
const j = await r.json().catch(() => ({ error: "the server answered " + r.status }));
if (!r.ok || j.error) throw new Error(j.error || "the server answered " + r.status);
return j;
}
const post = (body: object) => call({ method: "POST", body: JSON.stringify(body) });

const when = (iso: string) => new Date(iso).toLocaleString();

export default function AdminPage() {
const [report, setReport] = useState<SettingsReport | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);

const load = useCallback(async () => {
try { setReport(await call()); setErr(null); }
catch (e) { setErr(String((e as Error).message || e)); }
}, []);
useEffect(() => { void load(); }, [load]);

/** run one change; the answer is the report after it */
const act = async (key: string, body: object): Promise<boolean> => {
setBusy(key); setErr(null);
try { setReport(await post(body)); return true; }
catch (e) { setErr(String((e as Error).message || e)); return false; }
finally { setBusy(null); }
};

const clearCache = () => {
if (confirm("Clear the result cache for the whole deployment? Every analysis, Explore page and study list is computed again on its next request."))
void act("cache", { action: "clearCache" });
};

return <>
<SiteHeader page="administration — result cache and federation settings" />
<main className="repro-page admin-page">
<div className="repro-intro">
<h1>Admin</h1>
<p className="lede">
Settings here apply to the whole deployment, for everyone, within a few seconds of saving. A changed setting
gives every cached result a new key, so nothing computed under the old settings is served under the new ones.
</p>
</div>
{err && <ErrorAlert>{err}</ErrorAlert>}
{!report && !err && <p className="meta"><Loader2 className="inline size-4 animate-spin" /> loading…</p>}
{report && <>
<section className="admin-section" aria-labelledby="cache-h">
<h2 id="cache-h">Result cache</h2>
<p>
Analyses, oncoprints, Explore, study catalogs and the study list are cached for a day and shared by every reader
(Vercel&apos;s Runtime Cache in production, each server&apos;s own memory elsewhere). A data import, a new OncoKB
release and a deploy already clear it; clear it by hand after anything else that changes answers.
</p>
<p className="meta">
{report.cacheCleared ? <>Last cleared {when(report.cacheCleared.at)}{report.cacheCleared.by && <> by {report.cacheCleared.by}</>}.</> : "Not cleared from here yet."}
</p>
<Button variant="outline" size="sm" className={TB} onClick={clearCache} disabled={!!busy || !!report.unavailable}
title={report.unavailable ? "unavailable: " + report.unavailable : "clear every cached result, on every server"}>
{busy === "cache" ? <Loader2 className="animate-spin" /> : <Trash2 />}Clear the cache
</Button>
</section>

<section className="admin-section" aria-labelledby="fed-h">
<h2 id="fed-h">Federation settings</h2>
{report.unavailable
? <p className="error">Read-only: {report.unavailable}. The values below are the environment&apos;s.</p>
: <p className="meta">A value saved here overrides the environment variable of the same name until it is reverted.
Leave a field empty and save to override the variable with nothing.</p>}
{GROUPS.map((gr) => (
<div key={gr.key} className="admin-group">
<h3>{gr.title}</h3>
<p className="meta">{gr.about}</p>
{gr.key === "databases" && (
<table className="admin-table">
<thead><tr><th scope="col">connection</th><th scope="col">address</th><th scope="col">user · database · password</th></tr></thead>
<tbody>
{report.databases.map((d) => (
<tr key={d.id}>
<td><code>{d.id}</code> {d.label}</td>
<td><Value v={d.url || null} /></td>
<td><Value v={d.user || null} /> · <Value v={d.db || null} /> · <span className="meta">{d.passwordSet ? "password set (hidden)" : "no password"}</span></td>
</tr>
))}
</tbody>
</table>
)}
<table className="admin-table">
<thead><tr><th scope="col">setting</th><th scope="col">environment</th><th scope="col">in force</th></tr></thead>
<tbody>
{report.rows.filter((r) => r.group === gr.key).map((r) => (
<SettingLine key={r.name} row={r} busy={busy === r.name}
onSave={(value) => {
if (r.group === "patient-level" && !confirm("Change " + r.name + " for the whole deployment? It decides which patient-level data crosses between instances."))
return Promise.resolve(false);
return act(r.name, { action: "set", name: r.name, value });
}}
onReset={() => act(r.name, { action: "reset", name: r.name })} />
))}
</tbody>
</table>
</div>
))}
</section>

{report.log.length > 0 && (
<section className="admin-section" aria-labelledby="log-h">
<h2 id="log-h">Recent changes</h2>
<table className="admin-table">
<thead><tr><th scope="col">when</th><th scope="col">who</th><th scope="col">setting</th><th scope="col">from</th><th scope="col">to</th></tr></thead>
<tbody>
{report.log.map((l, i) => (
<tr key={i}>
<td className="meta">{when(l.at)}</td><td>{l.by ?? "—"}</td><td><code>{l.name}</code></td>
<td><Value v={l.old} /></td><td>{l.new == null ? <span className="meta">reverted to environment</span> : <Value v={l.new} />}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</>}
</main>
</>;
}

function Value({ v }: { v: string | null | undefined }) {
if (v == null) return <span className="meta">not set</span>;
if (v === "") return <span className="meta">empty</span>;
return <code className="admin-value">{v}</code>;
}

/** One setting: what the environment says, what is in force, and (when editable) a field that owns its draft. */
function SettingLine({ row, busy, onSave, onReset }: {
row: SettingRow; busy: boolean; onSave: (value: string) => Promise<boolean>; onReset: () => void;
}) {
const current = row.override ? row.override.value : row.env ?? "";
const [draft, setDraft] = useState(current);
const [editing, setEditing] = useState(false);
useEffect(() => { if (!editing) setDraft(current); }, [current, editing]);
const env = row.secret ? <span className="meta">{row.envSet ? "set (hidden)" : "not set"}</span> : <Value v={row.env} />;

return (
<tr className={row.override ? "overridden" : undefined}>
<td>
<code>{row.name}</code>
<div className="meta">{row.help}</div>
</td>
<td>{env}</td>
<td>
{!row.editable && (row.override ? <Value v={row.override.value} /> : <span className="meta">{row.secret ? "—" : "the environment's"}</span>)}
{row.editable && !editing && <div className="flex flex-wrap items-center gap-2">
{row.override ? <Value v={row.override.value} /> : <span className="meta">the environment&apos;s</span>}
<Button variant="ghost" size="sm" className={TB} onClick={() => setEditing(true)} disabled={busy}
aria-label={"edit " + row.name}>edit</Button>
{row.override && <Button variant="ghost" size="sm" className={TB} onClick={onReset} disabled={busy}
title={"remove the override; " + row.name + " is the environment's again"}>
{busy ? <Loader2 className="animate-spin" /> : <RotateCcw />}revert</Button>}
</div>}
{row.editable && editing && <form className="flex flex-wrap items-center gap-2"
onSubmit={(e) => { e.preventDefault(); void onSave(draft).then((ok) => { if (ok) setEditing(false); }); }}>
<Input value={draft} onChange={(e) => setDraft(e.target.value)} aria-label={row.name} autoFocus
className="h-8 min-w-60 flex-1 font-mono text-sm" />
<Button type="submit" size="sm" className={TB} disabled={busy}>{busy && <Loader2 className="animate-spin" />}save</Button>
<Button type="button" variant="ghost" size="sm" className={TB} onClick={() => { setDraft(current); setEditing(false); }}>cancel</Button>
</form>}
{row.override && <div className="meta">overridden {when(row.override.at)}</div>}
</td>
</tr>
);
}
3 changes: 3 additions & 0 deletions app/api/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerTools } from "../../../mcp/tools.ts";
import { cachedAnalysis, cachedContract } from "../../../lib/next-cache.ts";
import { asLoopbackPeer, isLoopbackRequest } from "../../../lib/loopback-peer.ts";
import { logInbound } from "../../../lib/federation-log.ts";
import { refreshSettings } from "../../../lib/settings.ts";

const handler = createMcpHandler(
(server) => registerTools(server, { runAnalysis: (doc) => cachedAnalysis(doc), buildContract: cachedContract }),
Expand All @@ -13,6 +14,8 @@ const handler = createMcpHandler(

// a call this app made to itself is answered as its simulated peer (lib/loopback-peer.ts)
const serve = async (req: Request) => {
// which tools exist and who counts as the simulated peer are settings an administrator may change
await refreshSettings();
const asPeer = isLoopbackRequest(req);
const call = req.method === "POST" ? await toolCall(req) : null;
const t0 = Date.now();
Expand Down
39 changes: 39 additions & 0 deletions app/api/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { connection } from "next/server";
import { revalidateTag } from "next/cache";
import { requireAdmin, AccessError } from "../../../lib/auth.ts";
import { markResultCacheCleared, setSetting, settingsReport } from "../../../lib/settings.ts";
import { RESULT_TAG } from "../../../lib/next-cache.ts";

// The administrator's page (/admin), for the users in ADMIN_EMAILS (lib/auth.ts).
// GET — every federation setting: its environment value (secrets only as set or not), the
// override in force, and the recent changes (lib/settings.ts)
// POST { action: "set", name, value } — override a setting for the whole deployment
// POST { action: "reset", name } — return it to its environment variable
// POST { action: "clearCache" } — clear the result cache (lib/next-cache.ts)
const fail = (e: unknown) => Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 });

export async function GET(req: Request) {
await connection();
try {
requireAdmin(req);
return Response.json(await settingsReport());
} catch (e) { return fail(e); }
}

export async function POST(req: Request) {
try {
const u = requireAdmin(req);
const body = await req.json().catch(() => ({}));
if (body.action === "clearCache") {
// a new key for every entry, in every process; and the entries themselves expire where the
// cache can expire them (Vercel's Runtime Cache, this process's memory)
await markResultCacheCleared(u.id);
revalidateTag(RESULT_TAG, { expire: 0 });
} else if (body.action === "set" || body.action === "reset") {
if (typeof body.name !== "string") throw new Error("name the setting");
if (body.action === "set" && typeof body.value !== "string") throw new Error("value must be text");
await setSetting(body.name, body.action === "set" ? body.value : null, u.id);
} else throw new Error("action must be set, reset or clearCache");
return Response.json(await settingsReport());
} catch (e) { return fail(e); }
}
8 changes: 5 additions & 3 deletions app/api/auth/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { loginEnabled, loginOptions, userFor } from "../../../lib/auth.ts";
import { isAdmin, loginEnabled, loginOptions, userFor } from "../../../lib/auth.ts";

// Whether this deployment signs people in, how, and who this browser is signed in as (lib/auth.ts).
// Whether this deployment signs people in, how, who this browser is signed in as and whether they
// administer it (lib/auth.ts).
export async function GET(req: Request) {
await connection();
return Response.json({ enabled: loginEnabled(), options: loginOptions(), user: userFor(req) });
const user = userFor(req);
return Response.json({ enabled: loginEnabled(), options: loginOptions(), user: user && { ...user, admin: isAdmin(user) } });
}
11 changes: 5 additions & 6 deletions app/api/catalog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { STUDY, withoutFailedQueries } from "../../../lib/ch";
import { studyRouting, restrictedStudies } from "../../../lib/federation";
import { catalogFor } from "../../../lib/study-catalog.ts";
import { peerForStudy, remoteCatalog } from "../../../lib/federation-remote.ts";
import { cacheLife } from "next/cache";
import { pack, throughCache, dataVersion } from "../../../lib/next-cache.ts";
import { pack, throughCache, cacheVersion, resultEntry } from "../../../lib/next-cache.ts";

export async function GET(req: Request) {
try {
Expand All @@ -12,27 +11,27 @@ export async function GET(req: Request) {
// A study PEER_MCP routes to a peer is described by that peer, over MCP: its database is
// not this instance's to read.
const peer = await peerForStudy(study);
if (peer) return Response.json(await throughCache(async () => peerCatalogEntry(study, peer.url, await dataVersion()), () => remoteCatalog(study, peer.url)));
if (peer) return Response.json(await throughCache(async () => peerCatalogEntry(study, peer.url, await cacheVersion()), () => remoteCatalog(study, peer.url)));
// A study lives on whichever instance holds it (AACR GENIE is not on the primary), so ask
// the same routing the analysis engine uses rather than assuming the default database.
// A study the routing withholds by policy is unreachable here too, not served from PRIMARY.
// Checked before the cache: a refusal is an answer to give, not one to keep.
if (!(await studyRouting()).has(study)) return Response.json({ error: restrictedStudies.has(study)
? "Study '" + study + "' is restricted and not available here" : "Study '" + study + "' not found" }, { status: 400 });
return Response.json(await throughCache(async () => catalogEntry(study, await dataVersion()), () => catalogFor(study)));
return Response.json(await throughCache(async () => catalogEntry(study, await cacheVersion()), () => catalogFor(study)));
} catch (e) {
return Response.json({ error: String((e as Error)?.message || e) }, { status: 500 });
}
}

async function catalogEntry(study: string, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => catalogFor(study)));
}

async function peerCatalogEntry(study: string, url: string, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => remoteCatalog(study, url)));
}
Loading
Loading