Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ 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 at `/admin`** (signed-in users in `ADMIN_EMAILS`; account menu →
Admin): every `'use cache: remote'` body starts with `resultEntry()`, which tags it
`RESULT_TAG`, and the clear expires that tag — on Vercel for every instance, elsewhere for the
process that answers. A new cached function must call `resultEntry()` too, or a clear misses it.
- **`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;
}
63 changes: 63 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";
// /admin — for the users in ADMIN_EMAILS (lib/auth.ts), reached from the account menu: clear the
// result cache (app/api/admin/route.ts).
import { useEffect, useState } from "react";
import { Loader2, Trash2 } from "lucide-react";
import { SiteHeader } from "../_ui/site-header.tsx";
import { Button } from "../_ui/button";
import { ErrorAlert } from "../_ui/error-alert.tsx";
import { TB } from "../_ui/toolbar.ts";

async function call<T>(init?: RequestInit): Promise<T> {
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;
}

export default function AdminPage() {
const [admin, setAdmin] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [cleared, setCleared] = useState<string | null>(null);

useEffect(() => {
call<{ admin: string }>().then((j) => setAdmin(j.admin), (e) => setErr(String((e as Error).message || e)));
}, []);

const clearCache = async () => {
if (!confirm("Clear the result cache for the whole deployment? Every analysis, Explore page and study list is computed again on its next request.")) return;
setBusy(true); setErr(null);
try { setCleared((await call<{ cleared: string }>({ method: "POST", body: JSON.stringify({ action: "clearCache" }) })).cleared); }
catch (e) { setErr(String((e as Error).message || e)); }
finally { setBusy(false); }
};

return <>
<SiteHeader page="administration" />
<main className="repro-page">
<div className="repro-intro">
<h1>Admin</h1>
<p className="lede">What you do here applies to the whole deployment, for everyone.</p>
</div>
{err && <ErrorAlert>{err}</ErrorAlert>}
{!admin && !err && <p className="meta"><Loader2 className="inline size-4 animate-spin" /> loading…</p>}
{admin && (
<section className="admin-section" aria-labelledby="cache-h">
<h2 id="cache-h">Result cache</h2>
<p>
Analyses, oncoprints, Explore, study catalogs, the study list and the data sets page are cached for a day and
shared by every reader: in Vercel&apos;s Runtime Cache in production, shared by every function instance; elsewhere
in the memory of each server process, so clearing it here clears the process that answers. A data import, a new
OncoKB release and a deploy already clear it; clear it by hand after anything else that changes answers.
</p>
<Button variant="outline" size="sm" className={TB} onClick={() => void clearCache()} disabled={busy}
title="expire every cached result; each is computed again on its next request">
{busy ? <Loader2 className="animate-spin" /> : <Trash2 />}Clear the cache
</Button>
{cleared && <p className="meta" role="status">Cleared at {new Date(cleared).toLocaleString()}.</p>}
</section>
)}
</main>
</>;
}
27 changes: 27 additions & 0 deletions app/api/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { connection } from "next/server";
import { revalidateTag } from "next/cache";
import { requireAdmin, AccessError } from "../../../lib/auth.ts";
import { RESULT_TAG } from "../../../lib/next-cache.ts";

// The administrator's page (/admin), for the users in ADMIN_EMAILS (lib/auth.ts).
// GET — whether this reader is an administrator (refused otherwise)
// POST { action: "clearCache" } — expire every cached result (RESULT_TAG, lib/next-cache.ts):
// on Vercel the Runtime Cache shared by every function instance, elsewhere the memory
// of the server process that answers
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 { return Response.json({ admin: requireAdmin(req).email }); }
catch (e) { return fail(e); }
}

export async function POST(req: Request) {
try {
requireAdmin(req);
const body = await req.json().catch(() => ({}));
if (body.action !== "clearCache") throw new Error("action must be clearCache");
revalidateTag(RESULT_TAG, { expire: 0 });
return Response.json({ cleared: new Date().toISOString() });
} 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) } });
}
7 changes: 3 additions & 4 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, dataVersion, resultEntry } from "../../../lib/next-cache.ts";

export async function GET(req: Request) {
try {
Expand All @@ -27,12 +26,12 @@ export async function GET(req: Request) {

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)));
}
5 changes: 2 additions & 3 deletions app/api/explore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { studyRouting, carriesPatientIds, instanceForStudy } from "../../../lib/
import { peerForStudy, remoteFacets } from "../../../lib/federation-remote.ts";
import { mergeExplore } from "../../../lib/explore.ts";
import { PRIMARY, withChContext, ensureChContext, withoutFailedQueries } from "../../../lib/ch.ts";
import { cacheLife } from "next/cache";
import { pack, throughCache, dataVersion } from "../../../lib/next-cache.ts";
import { pack, throughCache, dataVersion, resultEntry } from "../../../lib/next-cache.ts";
import { studyProfilesMany, resolveStudy } from "../../../lib/analysis.ts";
import { chQuery } from "../../../lib/ch.ts";
import type { ProfileInfo } from "../../../lib/profile-catalog.ts";
Expand Down Expand Up @@ -50,7 +49,7 @@ async function handle(req: Request) {

async function exploreEntry(body: any, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await ensureChContext(() => withoutFailedQueries(() => exploreFor(body))));
}

Expand Down
5 changes: 2 additions & 3 deletions app/api/studies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { studiesFor, type StudyRow } from "../../../lib/study-list.ts";
import { peerMcpEntries, peerPatientKinds, remoteStudies } from "../../../lib/federation-remote.ts";
import { instanceForStudy } from "../../../lib/federation.ts";
import type { StudyAccess } from "../../../lib/view-coverage.ts";
import { cacheLife } from "next/cache";
import { pack, throughCache, dataVersion } from "../../../lib/next-cache.ts";
import { pack, throughCache, dataVersion, resultEntry } from "../../../lib/next-cache.ts";
import { connection } from "next/server";

async function allStudies(): Promise<StudyRow[]> {
Expand All @@ -30,7 +29,7 @@ async function allStudies(): Promise<StudyRow[]> {
}
async function studiesEntry(_version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => allStudies()));
}
/** what the reachable instances returned, when one of them did not answer */
Expand Down
5 changes: 2 additions & 3 deletions app/datasets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
// /datasets — every study the configured instances hold, with how many samples carry each kind of
// data: cBioPortal's Data Sets page, built from the same warehouse tables every view queries.
import { cacheLife } from "next/cache";
import { connection } from "next/server";
import { allDatasets, type DatasetRow } from "../../lib/datasets.ts";
import { withoutFailedQueries } from "../../lib/ch.ts";
import { pack, throughCache, dataVersion } from "../../lib/next-cache.ts";
import { pack, throughCache, dataVersion, resultEntry } from "../../lib/next-cache.ts";
import { DatasetsTable } from "./DatasetsTable.tsx";
import { PageTools } from "../_agent/PageTools.tsx";

export const metadata = { title: "Data sets — cbio-lab" };

async function datasetsEntry(_version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
const r = await withoutFailedQueries(() => allDatasets());
// a catalog missing an instance is served, not kept for a day
if (r.unreachable.length) throw new Error("an instance did not answer");
Expand Down
7 changes: 7 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1732,3 +1732,10 @@ td.camp-links { flex-direction: column; align-items: flex-start; gap: var(--s1);
.dt-table tr.dt-pick.sel td { background: var(--accent-soft); }
.cohortsum-skel { display: inline-block; width: 4rem; height: 0.9rem; }
} /* @layer legacy */

/* ---------------------------------------------------------------------------
/admin (app/admin/page.tsx)
--------------------------------------------------------------------------- */
.admin-section { margin-top: var(--s5); }
.admin-section h2 { font-family: "IBM Plex Serif", Georgia, serif; font-size: var(--t-xl); margin: 0 0 var(--s2); }
.admin-section > p { max-width: 75ch; line-height: 1.6; }
2 changes: 1 addition & 1 deletion app/lab/custom-data-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function referencedDatasets(x: unknown): string[] {

/** A document's pin: the name it uses -> a stored dataset at one version. */
export interface UploadBinding { as: string; dataset: string; version: number }
export interface SignedInUser { id: string; email: string; name: string | null }
export interface SignedInUser { id: string; email: string; name: string | null; /** administers this deployment (/admin) */ admin?: boolean }
/** How this deployment signs people in (lib/auth.ts): its provider's name, and the development sign-in. */
export interface LoginOptions { oidc: string | null; dev: boolean }
export interface ServerState {
Expand Down
7 changes: 4 additions & 3 deletions app/lab/login-button.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";
// Top-right "Sign in", or once signed in the account menu (who, and sign out), like
// the portal's "Logged in as" menu. Signing in lets a
// Top-right "Sign in", or once signed in the account menu (who, Admin for an administrator, and sign
// out), like the portal's "Logged in as" menu. Signing in lets a
// reader save and open their own cohorts and analyses and use custom data (lib/auth.ts). A
// deployment offers its provider (Google, MSK…), the development sign-in, or both; with only a
// provider the button goes straight there. Shown only where the deployment signs people in.
import { useCallback, useEffect, useState } from "react";
import { ChevronDown, LogIn, LogOut, UserRound } from "lucide-react";
import { ChevronDown, LogIn, LogOut, Settings, UserRound } from "lucide-react";
import { loadCustomData, devSignIn, signInWithProvider, logOut, onLoginRequest, serverState } from "./custom-data-client.ts";
import { useCustomDataState } from "./custom-data-panel.tsx";
import { Button } from "../_ui/button";
Expand Down Expand Up @@ -60,6 +60,7 @@ export default function LoginButton() {
<div className="meta">{server.user?.email}</div>
</div>
<MenuSeparator />
{server.user?.admin && <MenuItem asChild onClick={close}><a href="/admin" title="administer this deployment: clear the result cache"><Settings />Admin</a></MenuItem>}
<MenuItem onClick={() => { close(); void logOut(); }}><LogOut />Sign out</MenuItem>
</>}
</GearMenu></span>
Expand Down
17 changes: 17 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
// Either way the result is a session: an httpOnly cookie carrying the user's id, email and name,
// signed with AUTH_SESSION_SECRET (HMAC-SHA256). Requests are decided from the cookie alone, so a
// disabled user keeps their session until it expires (SESSION_DAYS); signing in again is refused.
//
// Administrators (/admin: clearing the result cache) are the signed-in users whose email is in
// ADMIN_EMAILS. With the development sign-in anyone can claim an
// address, so there it is a convenience, not a guard; it is refused on Vercel production.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export const SESSION_COOKIE = "cl_session";
Expand Down Expand Up @@ -101,3 +105,16 @@ export function requirePrincipal(req: Request, what = "use saved cohorts, analys
if (!p) throw new AccessError("sign in to " + what, 403);
return p;
}

const adminEmails = (): string[] => (process.env.ADMIN_EMAILS || "").split(/[,\s]+/).map((e) => e.trim().toLowerCase()).filter(Boolean);

export const isAdmin = (u: SessionUser | null): boolean => !!u?.email && adminEmails().includes(u.email.toLowerCase());

/** The signed-in administrator behind a request; refused for anyone else. */
export function requireAdmin(req: Request): SessionUser {
if (!loginEnabled()) throw new AccessError("this deployment has no sign-in, so it has no administrators", 404);
const u = userFor(req);
if (!u) throw new AccessError("sign in as an administrator to see this page", 403);
if (!isAdmin(u)) throw new AccessError(u.email + " is not an administrator of this deployment (ADMIN_EMAILS)", 403);
return u;
}
22 changes: 16 additions & 6 deletions lib/next-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// - a data import or a new OncoKB release: `dataVersion()` is an argument of every cached
// function, so new data means new keys
// - time: `cacheLife("days")` regenerates an entry a day after it was written
// - an administrator (/admin): every entry carries RESULT_TAG, which clearing the cache expires
//
// Only a value the function returns is stored. A thrown error, a refused request or a query the
// reader abandoned is never cached, so every path that means "no answer" must throw. Each cached
Expand All @@ -27,11 +28,11 @@
//
// A cached function takes only serializable arguments and computes its answer itself, so each
// route keeps its own small one (`catalogEntry`, `studiesEntry`, `exploreEntry`) beside the code
// it caches, built from `pack`, `throughCache` and `dataVersion` here.
// it caches, built from `pack`, `throughCache`, `dataVersion` and `resultEntry` here.
//
// Next-only: `next/cache` exists only under the Next runtime, so the stdio MCP server, the CLI
// and the tests use the uncached engine directly.
import { cacheLife } from "next/cache";
import { cacheLife, cacheTag } from "next/cache";
import { gzipSync, gunzipSync } from "node:zlib";
import { chQuery, directConnections, loopbackOnlyConn, connById, withoutFailedQueries, type ChConn } from "./ch.ts";
import { oncokbPin } from "./annotation.ts";
Expand Down Expand Up @@ -91,6 +92,15 @@ export function dataVersion(): Promise<string> {
return value;
}

/** Every cached result's tag, which clearing the cache from /admin expires. */
export const RESULT_TAG = "results";

/** The lifetime and tag of every cached result: called first in each `'use cache: remote'` body. */
export function resultEntry(): void {
cacheLife("days");
cacheTag(RESULT_TAG);
}

/**
* One cohort's size on one connection, by its compiled SQL — which already says everything that
* decides it (definition, study, OncoKB pin, profiles). The page asks for a document's cohorts and
Expand All @@ -99,7 +109,7 @@ export function dataVersion(): Promise<string> {
*/
async function countEntry(connId: string, sql: string, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
const conn = connById(connId);
if (!conn) throw new Error("no connection '" + connId + "'");
return pack(await withoutFailedQueries(async () =>
Expand Down Expand Up @@ -131,7 +141,7 @@ const RISK_FIT_CACHE_MAX_PATIENTS = 60_000;
*/
async function riskFitEntry(args: RiskFitArgs, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => riskFitDirect(args)));
}
export async function cachedRiskFit(a: RiskFitArgs & { patients: number }): Promise<RiskFit> {
Expand All @@ -146,7 +156,7 @@ const ROUTE_OPTS = { count: cachedCount, riskFit: cachedRiskFit };

async function analysisEntry(doc: AnalysisDoc, oncoprint: "svg" | "spec", _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => runAnalysis(doc, { oncoprint, ...ROUTE_OPTS })));
}
export async function cachedAnalysis(doc: AnalysisDoc, opts: { oncoprint?: "svg" | "spec" } = {}): Promise<AnalysisResult> {
Expand All @@ -158,7 +168,7 @@ export async function cachedAnalysis(doc: AnalysisDoc, opts: { oncoprint?: "svg"

async function contractEntry(spec: OncoprintSpec, provenance: boolean, _version: string): Promise<string> {
"use cache: remote";
cacheLife("days");
resultEntry();
return pack(await withoutFailedQueries(() => buildContract(spec, provenance ? { log: [], provenance } : {})));
}
/**
Expand Down
Loading
Loading