From ced27b5db5b244a37a7d19df244764d7ddec1339 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Tue, 27 Oct 2026 13:22:20 +0000 Subject: [PATCH] Admin page: clear the result cache A signed-in user whose email is in ADMIN_EMAILS gets "Admin" in the account menu, opening /admin, whose one action is clearing the result cache for the whole deployment. Every 'use cache: remote' body now starts with resultEntry() (lib/next-cache.ts), which sets the day's lifetime as before and tags the entry RESULT_TAG; POST /api/admin {action: "clearCache"} expires that tag with revalidateTag(tag, {expire: 0}). On Vercel that is the Runtime Cache shared by every function instance; elsewhere it is the answering process's own memory. No database change: nothing records the clear. requireAdmin (lib/auth.ts) refuses with 404 where the deployment has no sign-in and 403 for anyone signed out or not listed. With the development sign-in anyone can claim an address, so there it is a convenience, not a guard; that sign-in is already refused on Vercel production. Verified on an isolated next dev (in-process Postgres, dev sign-in): Explore 55 ms cached -> 707 ms recomputed after a clear -> 36 ms cached again, the study list likewise; a signed-out and a non-admin request are refused; a non-admin has no menu item; the page and its confirm flow driven in Chromium with no console errors. test/auth.test.ts covers who is an admin; test/result-cache-tag.test.ts fails on a cached body without resultEntry() (checked by planting one). Gate: lint, build, unit (1674) and postgres (66) passed; stopped before e2e. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q3ezTZPdCcLJ4FcYtykedj --- CLAUDE.md | 4 +++ app/admin/layout.tsx | 5 +++ app/admin/page.tsx | 63 +++++++++++++++++++++++++++++++++++ app/api/admin/route.ts | 27 +++++++++++++++ app/api/auth/route.ts | 8 +++-- app/api/catalog/route.ts | 7 ++-- app/api/explore/route.ts | 5 ++- app/api/studies/route.ts | 5 ++- app/datasets/page.tsx | 5 ++- app/globals.css | 7 ++++ app/lab/custom-data-client.ts | 2 +- app/lab/login-button.tsx | 7 ++-- lib/auth.ts | 17 ++++++++++ lib/next-cache.ts | 22 ++++++++---- test/auth.test.ts | 23 +++++++++++++ test/result-cache-tag.test.ts | 23 +++++++++++++ 16 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/route.ts create mode 100644 test/result-cache-tag.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1115db95..38cad534 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 00000000..a3d8c9a0 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,5 @@ +export const metadata = { title: "Admin — cbio-lab" }; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 00000000..9dcde5f1 --- /dev/null +++ b/app/admin/page.tsx @@ -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(init?: RequestInit): Promise { + 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(null); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [cleared, setCleared] = useState(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 <> + +
+
+

Admin

+

What you do here applies to the whole deployment, for everyone.

+
+ {err && {err}} + {!admin && !err &&

loading…

} + {admin && ( +
+

Result cache

+

+ 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'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. +

+ + {cleared &&

Cleared at {new Date(cleared).toLocaleString()}.

} +
+ )} +
+ ; +} diff --git a/app/api/admin/route.ts b/app/api/admin/route.ts new file mode 100644 index 00000000..630e4c88 --- /dev/null +++ b/app/api/admin/route.ts @@ -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); } +} diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index c8ed160d..8fe6bc9a 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -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) } }); } diff --git a/app/api/catalog/route.ts b/app/api/catalog/route.ts index 1cfec494..f2091bc9 100644 --- a/app/api/catalog/route.ts +++ b/app/api/catalog/route.ts @@ -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 { @@ -27,12 +26,12 @@ export async function GET(req: Request) { async function catalogEntry(study: string, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await withoutFailedQueries(() => catalogFor(study))); } async function peerCatalogEntry(study: string, url: string, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await withoutFailedQueries(() => remoteCatalog(study, url))); } diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 31133ff3..773d0d78 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -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"; @@ -50,7 +49,7 @@ async function handle(req: Request) { async function exploreEntry(body: any, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await ensureChContext(() => withoutFailedQueries(() => exploreFor(body)))); } diff --git a/app/api/studies/route.ts b/app/api/studies/route.ts index 9fe78099..b282dbbe 100644 --- a/app/api/studies/route.ts +++ b/app/api/studies/route.ts @@ -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 { @@ -30,7 +29,7 @@ async function allStudies(): Promise { } async function studiesEntry(_version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await withoutFailedQueries(() => allStudies())); } /** what the reachable instances returned, when one of them did not answer */ diff --git a/app/datasets/page.tsx b/app/datasets/page.tsx index 4a19e514..7875f61c 100644 --- a/app/datasets/page.tsx +++ b/app/datasets/page.tsx @@ -1,10 +1,9 @@ // /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"; @@ -12,7 +11,7 @@ export const metadata = { title: "Data sets — cbio-lab" }; async function datasetsEntry(_version: string): Promise { "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"); diff --git a/app/globals.css b/app/globals.css index 22f2944c..f801a86f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/lab/custom-data-client.ts b/app/lab/custom-data-client.ts index edb00c93..0bb85542 100644 --- a/app/lab/custom-data-client.ts +++ b/app/lab/custom-data-client.ts @@ -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 { diff --git a/app/lab/login-button.tsx b/app/lab/login-button.tsx index 3ea671c7..1bbb949e 100644 --- a/app/lab/login-button.tsx +++ b/app/lab/login-button.tsx @@ -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"; @@ -60,6 +60,7 @@ export default function LoginButton() {
{server.user?.email}
+ {server.user?.admin && Admin} { close(); void logOut(); }}>Sign out } diff --git a/lib/auth.ts b/lib/auth.ts index 709b7ab2..44a54c53 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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"; @@ -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; +} diff --git a/lib/next-cache.ts b/lib/next-cache.ts index 58275018..b1f708ab 100644 --- a/lib/next-cache.ts +++ b/lib/next-cache.ts @@ -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 @@ -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"; @@ -91,6 +92,15 @@ export function dataVersion(): Promise { 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 @@ -99,7 +109,7 @@ export function dataVersion(): Promise { */ async function countEntry(connId: string, sql: string, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); const conn = connById(connId); if (!conn) throw new Error("no connection '" + connId + "'"); return pack(await withoutFailedQueries(async () => @@ -131,7 +141,7 @@ const RISK_FIT_CACHE_MAX_PATIENTS = 60_000; */ async function riskFitEntry(args: RiskFitArgs, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await withoutFailedQueries(() => riskFitDirect(args))); } export async function cachedRiskFit(a: RiskFitArgs & { patients: number }): Promise { @@ -146,7 +156,7 @@ const ROUTE_OPTS = { count: cachedCount, riskFit: cachedRiskFit }; async function analysisEntry(doc: AnalysisDoc, oncoprint: "svg" | "spec", _version: string): Promise { "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 { @@ -158,7 +168,7 @@ export async function cachedAnalysis(doc: AnalysisDoc, opts: { oncoprint?: "svg" async function contractEntry(spec: OncoprintSpec, provenance: boolean, _version: string): Promise { "use cache: remote"; - cacheLife("days"); + resultEntry(); return pack(await withoutFailedQueries(() => buildContract(spec, provenance ? { log: [], provenance } : {}))); } /** diff --git a/test/auth.test.ts b/test/auth.test.ts index a598fe79..ee60f968 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -43,6 +43,29 @@ describe("which sign-ins a deployment offers", () => { }); }); +describe("administrators", () => { + beforeEach(() => { reset(); process.env.AUTH_DEV_LOGIN = "1"; process.env.AUTH_SESSION_SECRET = "a".repeat(40); delete process.env.ADMIN_EMAILS; }); + after(() => { process.env = { ...env }; }); + const signedIn = (u = U) => req(auth.sessionCookie(req(), u).split(";")[0]); + + test("are the signed-in users ADMIN_EMAILS names, whatever the case", () => { + process.env.ADMIN_EMAILS = "someone@example.org, ADA@example.org"; + assert.equal(auth.isAdmin(U), true); + assert.deepEqual(auth.requireAdmin(signedIn()), U); + assert.equal(auth.isAdmin({ ...U, email: "bob@example.org" }), false); + assert.throws(() => auth.requireAdmin(signedIn({ ...U, email: "bob@example.org" })), (e) => e instanceof auth.AccessError && e.status === 403 && /bob@example.org is not an administrator/.test(e.message)); + }); + test("there are none without ADMIN_EMAILS, nobody signed in, or sign-in", () => { + assert.equal(auth.isAdmin(U), false); + assert.equal(auth.isAdmin(null), false); + assert.throws(() => auth.requireAdmin(signedIn()), /not an administrator/); + process.env.ADMIN_EMAILS = U.email; + assert.throws(() => auth.requireAdmin(req()), (e) => e instanceof auth.AccessError && e.status === 403); + delete process.env.AUTH_DEV_LOGIN; + assert.throws(() => auth.requireAdmin(req()), (e) => e instanceof auth.AccessError && e.status === 404); + }); +}); + describe("the session cookie", () => { beforeEach(() => { reset(); process.env.AUTH_DEV_LOGIN = "1"; process.env.AUTH_SESSION_SECRET = "a".repeat(40); }); after(() => { process.env = { ...env }; }); diff --git a/test/result-cache-tag.test.ts b/test/result-cache-tag.test.ts new file mode 100644 index 00000000..7d556fab --- /dev/null +++ b/test/result-cache-tag.test.ts @@ -0,0 +1,23 @@ +// Clearing the result cache from /admin expires one tag (RESULT_TAG, lib/next-cache.ts), so it +// clears only entries that carry it: every `'use cache: remote'` body must start with resultEntry(), +// which sets the tag with the lifetime. A cached function written without it would survive a clear. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; + +test("every cached function carries the tag a cache clear expires", () => { + const files: string[] = []; + const walk = (d: string) => { for (const f of readdirSync(d)) { const p = d + "/" + f; if (statSync(p).isDirectory()) walk(p); else if (/\.tsx?$/.test(f)) files.push(p); } }; + for (const d of ["app", "lib", "mcp"]) walk(d); + const bodies: string[] = [], untagged: string[] = []; + for (const f of files) { + const lines = readFileSync(f, "utf8").split("\n"); + lines.forEach((l, i) => { + if (!/^\s*["']use cache(: \w+)?["'];/.test(l)) return; + bodies.push(f + ":" + (i + 1)); + if (!/^\s*resultEntry\(\);/.test(lines[i + 1] || "")) untagged.push(f + ":" + (i + 1)); + }); + } + assert.ok(bodies.length >= 9, "expected the cached functions of lib/next-cache.ts and the routes; found " + bodies.length); + assert.deepEqual(untagged, [], "start these cached bodies with resultEntry() (lib/next-cache.ts), or /admin's clear misses them"); +});