Skip to content

Commit 6ddd88f

Browse files
committed
feat(admin): add cache management page and endpoints
New GET /admin/cache lists app-owned Redis namespaces (int:* and cache:*) with key counts; POST /admin/cache/clear scan+unlinks a namespace or all app prefixes. It deliberately never calls FLUSHDB/FLUSHALL and rejects out-of-prefix patterns with 400, so an always-on endpoint can't wipe a shared Redis. Adds a Cache admin page (CacheManager) with per-row and clear-all actions behind a confirm dialog, plus the sidebar/topbar nav. Namespace helpers copied from the CLI into apps/api/src/utils.
1 parent 2b29775 commit 6ddd88f

9 files changed

Lines changed: 644 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { Readable } from "node:stream";
2+
import Fastify, { type FastifyInstance } from "fastify";
3+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4+
import { mockAdminSession } from "./admin-test-helpers.js";
5+
6+
// Auth guard mock — all three exports required.
7+
const fakeSession = mockAdminSession();
8+
const mockRequireAdmin = vi.fn().mockResolvedValue(fakeSession);
9+
const mockGetAdminSession = vi.fn().mockReturnValue(fakeSession);
10+
const mockTryAdminSession = vi.fn().mockResolvedValue(fakeSession);
11+
12+
vi.mock("../../utils/require-admin.js", () => ({
13+
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
14+
getAdminSession: (...args: unknown[]) => mockGetAdminSession(...args),
15+
tryAdminSession: (...args: unknown[]) => mockTryAdminSession(...args),
16+
}));
17+
18+
// Audit log.
19+
const mockWriteAuditLog = vi.fn().mockResolvedValue(undefined);
20+
vi.mock("../../utils/audit-log.js", () => ({
21+
writeAuditLog: (...args: unknown[]) => mockWriteAuditLog(...args),
22+
}));
23+
24+
// Fake Redis. `scanStream` returns a stream emitting one array of the keys
25+
// whose name matches the requested glob prefix. `flushdb` is a spy so the
26+
// "never wipes the whole DB" guarantee is assertable.
27+
const cacheStore = new Map<string, string>([
28+
["int:geocoding:a", "1"],
29+
["int:geocoding:b", "1"],
30+
["int:routing:c", "1"],
31+
["cache:geocode:d", "1"],
32+
["session:user:1", "1"], // NOT app-owned — must never be touched.
33+
]);
34+
35+
function prefixFromGlob(match: string): string {
36+
return match.endsWith("*") ? match.slice(0, -1) : match;
37+
}
38+
39+
const mockScanStream = vi.fn((opts: { match: string }) => {
40+
const prefix = prefixFromGlob(opts.match);
41+
const keys = [...cacheStore.keys()].filter((k) => k.startsWith(prefix));
42+
return Readable.from([keys]);
43+
});
44+
45+
const mockUnlink = vi.fn((...keys: string[]) => {
46+
let removed = 0;
47+
for (const k of keys) {
48+
if (cacheStore.delete(k)) removed += 1;
49+
}
50+
return Promise.resolve(removed);
51+
});
52+
53+
const mockFlushdb = vi.fn(() => Promise.resolve("OK"));
54+
55+
const fakeRedis = {
56+
scanStream: mockScanStream,
57+
unlink: mockUnlink,
58+
flushdb: mockFlushdb,
59+
};
60+
61+
vi.mock("../../redis.js", () => ({ redis: fakeRedis }));
62+
63+
let app: FastifyInstance;
64+
65+
beforeAll(async () => {
66+
const { adminCacheRoute } = await import("../admin-cache.js");
67+
app = Fastify({ logger: false });
68+
await app.register(adminCacheRoute);
69+
await app.ready();
70+
});
71+
72+
afterAll(() => app.close());
73+
afterEach(() => {
74+
vi.clearAllMocks();
75+
// Restore the store between tests so destructive tests don't leak.
76+
cacheStore.clear();
77+
cacheStore.set("int:geocoding:a", "1");
78+
cacheStore.set("int:geocoding:b", "1");
79+
cacheStore.set("int:routing:c", "1");
80+
cacheStore.set("cache:geocode:d", "1");
81+
cacheStore.set("session:user:1", "1");
82+
});
83+
84+
describe("GET /admin/cache", () => {
85+
it("lists app-owned namespaces with key counts", async () => {
86+
const res = await app.inject({ method: "GET", url: "/admin/cache" });
87+
88+
expect(res.statusCode).toBe(200);
89+
const body = res.json() as { namespaces: Array<{ namespace: string; keyCount: number }> };
90+
expect(Array.isArray(body.namespaces)).toBe(true);
91+
92+
const byName = Object.fromEntries(body.namespaces.map((n) => [n.namespace, n.keyCount]));
93+
expect(byName["int:geocoding"]).toBe(2);
94+
expect(byName["int:routing"]).toBe(1);
95+
expect(byName["cache:geocode"]).toBe(1);
96+
// The non-app-owned `session:*` prefix is never scanned.
97+
expect(byName["session:user"]).toBeUndefined();
98+
});
99+
100+
it("rejects unauthenticated requests with 401", async () => {
101+
mockRequireAdmin.mockRejectedValueOnce(
102+
Object.assign(new Error("Authentication required"), { statusCode: 401 }),
103+
);
104+
const res = await app.inject({ method: "GET", url: "/admin/cache" });
105+
expect(res.statusCode).toBe(401);
106+
});
107+
});
108+
109+
describe("POST /admin/cache/clear", () => {
110+
it("clears a single namespace and audit-logs it", async () => {
111+
const res = await app.inject({
112+
method: "POST",
113+
url: "/admin/cache/clear",
114+
payload: { namespace: "geocoding" },
115+
});
116+
117+
expect(res.statusCode).toBe(200);
118+
expect(res.json()).toEqual({ deleted: 2 });
119+
// Only the matching prefix was removed; other keys remain.
120+
expect(cacheStore.has("int:routing:c")).toBe(true);
121+
expect(cacheStore.has("cache:geocode:d")).toBe(true);
122+
expect(cacheStore.has("session:user:1")).toBe(true);
123+
expect(mockWriteAuditLog).toHaveBeenCalledWith(
124+
expect.objectContaining({
125+
action: "cache.clear",
126+
targetType: "cache",
127+
details: { namespace: "geocoding" },
128+
}),
129+
);
130+
expect(mockFlushdb).not.toHaveBeenCalled();
131+
});
132+
133+
it("clears all app-owned prefixes but never the rest of the DB", async () => {
134+
const res = await app.inject({ method: "POST", url: "/admin/cache/clear", payload: {} });
135+
136+
expect(res.statusCode).toBe(200);
137+
// int:* (3) + cache:* (1) removed; session:* untouched.
138+
expect(res.json()).toEqual({ deleted: 4 });
139+
expect(cacheStore.has("session:user:1")).toBe(true);
140+
expect(mockFlushdb).not.toHaveBeenCalled();
141+
});
142+
143+
it('treats namespace "all" the same as an omitted namespace', async () => {
144+
const res = await app.inject({
145+
method: "POST",
146+
url: "/admin/cache/clear",
147+
payload: { namespace: "all" },
148+
});
149+
150+
expect(res.statusCode).toBe(200);
151+
expect(res.json()).toEqual({ deleted: 4 });
152+
expect(mockFlushdb).not.toHaveBeenCalled();
153+
});
154+
155+
it("rejects a namespace that resolves outside the app prefixes with 400", async () => {
156+
const res = await app.inject({
157+
method: "POST",
158+
url: "/admin/cache/clear",
159+
payload: { namespace: "*" },
160+
});
161+
162+
expect(res.statusCode).toBe(400);
163+
expect(mockUnlink).not.toHaveBeenCalled();
164+
expect(mockFlushdb).not.toHaveBeenCalled();
165+
});
166+
167+
it("rejects unauthenticated requests with 401", async () => {
168+
mockRequireAdmin.mockRejectedValueOnce(
169+
Object.assign(new Error("Authentication required"), { statusCode: 401 }),
170+
);
171+
const res = await app.inject({ method: "POST", url: "/admin/cache/clear", payload: {} });
172+
expect(res.statusCode).toBe(401);
173+
});
174+
});
175+
176+
describe("when Redis is disabled", () => {
177+
it("GET returns an empty list and POST reports zero deletions", async () => {
178+
vi.resetModules();
179+
vi.doMock("../../redis.js", () => ({ redis: null }));
180+
vi.doMock("../../utils/require-admin.js", () => ({
181+
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
182+
getAdminSession: (...args: unknown[]) => mockGetAdminSession(...args),
183+
tryAdminSession: (...args: unknown[]) => mockTryAdminSession(...args),
184+
}));
185+
vi.doMock("../../utils/audit-log.js", () => ({
186+
writeAuditLog: (...args: unknown[]) => mockWriteAuditLog(...args),
187+
}));
188+
189+
const { adminCacheRoute } = await import("../admin-cache.js");
190+
const nullApp = Fastify({ logger: false });
191+
await nullApp.register(adminCacheRoute);
192+
await nullApp.ready();
193+
194+
const listRes = await nullApp.inject({ method: "GET", url: "/admin/cache" });
195+
expect(listRes.json()).toEqual({ namespaces: [] });
196+
197+
const clearRes = await nullApp.inject({
198+
method: "POST",
199+
url: "/admin/cache/clear",
200+
payload: {},
201+
});
202+
expect(clearRes.json()).toEqual({ deleted: 0 });
203+
204+
await nullApp.close();
205+
vi.doUnmock("../../redis.js");
206+
vi.resetModules();
207+
});
208+
});

apps/api/src/routes/admin-cache.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { FastifyInstance } from "fastify";
2+
import { redis } from "../redis";
3+
import { writeAuditLog } from "../utils/audit-log";
4+
import {
5+
APP_CACHE_PREFIXES,
6+
aggregateNamespaces,
7+
isAppCachePattern,
8+
resolveCachePattern,
9+
} from "../utils/cache-namespaces";
10+
import { getAdminSession, requireAdmin } from "../utils/require-admin";
11+
12+
// Globs covering exactly the app-owned key prefixes. The list + clear-all paths
13+
// scan these and nothing else, so the endpoint can never touch keys outside the
14+
// application's cache (the CLI's `--all` FLUSHDB is intentionally NOT mirrored).
15+
const APP_CACHE_GLOBS = APP_CACHE_PREFIXES.map((prefix) => `${prefix}*`);
16+
17+
const SCAN_COUNT = 500;
18+
const UNLINK_BATCH = 500;
19+
20+
type RedisClient = NonNullable<typeof redis>;
21+
22+
/** Collect every key matching a glob via a non-blocking SCAN stream. */
23+
async function scanKeys(client: RedisClient, match: string): Promise<string[]> {
24+
const keys: string[] = [];
25+
const stream = client.scanStream({ match, count: SCAN_COUNT });
26+
for await (const batch of stream as AsyncIterable<string[]>) {
27+
keys.push(...batch);
28+
}
29+
return keys;
30+
}
31+
32+
/** SCAN + UNLINK the keys matching a glob in batches. Returns the count removed. */
33+
async function clearPattern(client: RedisClient, match: string): Promise<number> {
34+
const keys = await scanKeys(client, match);
35+
let deleted = 0;
36+
for (let i = 0; i < keys.length; i += UNLINK_BATCH) {
37+
const batch = keys.slice(i, i + UNLINK_BATCH);
38+
if (batch.length > 0) deleted += await client.unlink(...batch);
39+
}
40+
return deleted;
41+
}
42+
43+
export async function adminCacheRoute(app: FastifyInstance): Promise<void> {
44+
app.addHook("preHandler", async (request, _reply) => {
45+
request.adminSession = await requireAdmin(request);
46+
});
47+
48+
// GET /admin/cache — list app-owned cache namespaces with their key counts.
49+
app.get("/admin/cache", async () => {
50+
if (!redis) return { namespaces: [] };
51+
52+
const keys: string[] = [];
53+
for (const glob of APP_CACHE_GLOBS) {
54+
keys.push(...(await scanKeys(redis, glob)));
55+
}
56+
57+
const namespaces = aggregateNamespaces(keys).map((n) => ({
58+
namespace: n.namespace,
59+
keyCount: n.count,
60+
}));
61+
return { namespaces };
62+
});
63+
64+
// POST /admin/cache/clear — scoped clear of the app-owned cache prefixes.
65+
// Body: { namespace?: string }. Omitted or "all" clears every app prefix.
66+
// NEVER runs FLUSHDB/FLUSHALL — an always-on web endpoint must not be able to
67+
// wipe a Redis that may be shared with other tenants.
68+
app.post<{ Body: { namespace?: unknown } }>("/admin/cache/clear", async (request, reply) => {
69+
const raw = request.body?.namespace;
70+
if (raw !== undefined && typeof raw !== "string") {
71+
return reply.status(400).send({ error: "namespace must be a string" });
72+
}
73+
const namespace = typeof raw === "string" ? raw.trim() : undefined;
74+
const clearAll = !namespace || namespace === "all";
75+
76+
// Validate a targeted namespace resolves to an app-owned prefix before we do
77+
// anything destructive — reject e.g. `*` that would match unrelated keys.
78+
if (!clearAll) {
79+
const pattern = resolveCachePattern(namespace as string);
80+
if (!isAppCachePattern(pattern)) {
81+
return reply.status(400).send({
82+
error: "namespace must resolve to an app-owned cache prefix (int:* or cache:*)",
83+
});
84+
}
85+
}
86+
87+
const adminSession = getAdminSession(request);
88+
await writeAuditLog({
89+
actorId: adminSession.user.id,
90+
targetType: "cache",
91+
targetId: clearAll ? "all" : (namespace ?? null),
92+
action: "cache.clear",
93+
details: { namespace: clearAll ? "all" : namespace },
94+
request,
95+
});
96+
97+
if (!redis) return { deleted: 0 };
98+
99+
let deleted = 0;
100+
if (clearAll) {
101+
for (const glob of APP_CACHE_GLOBS) {
102+
deleted += await clearPattern(redis, glob);
103+
}
104+
} else {
105+
deleted = await clearPattern(redis, resolveCachePattern(namespace as string));
106+
}
107+
return { deleted };
108+
});
109+
}

apps/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "./integration-host";
2121
import { redis } from "./redis";
2222
import { adminRoute } from "./routes/admin";
23+
import { adminCacheRoute } from "./routes/admin-cache";
2324
import { registerCapabilityBindingRoutes } from "./routes/admin-capability-bindings";
2425
import { registerAdminComposeRoutes } from "./routes/admin-compose";
2526
import { registerAdminServiceReposRoutes } from "./routes/admin-service-repos";
@@ -352,6 +353,7 @@ await server.register(adminServicesRoute, { prefix: "/api" });
352353
await server.register(dataManagerRoute, { prefix: "/api" });
353354
await server.register(adminSettingsRoute, { prefix: "/api" });
354355
await server.register(adminStoreRoute, { prefix: "/api" });
356+
await server.register(adminCacheRoute, { prefix: "/api" });
355357
await server.register(attributionRoute, { prefix: "/api" });
356358
await registerCapabilityBindingRoutes(server);
357359
await registerAdminServiceReposRoutes(server);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Cache key-namespace helpers shared with the CLI's `cache list|clear` logic.
3+
*
4+
* The grouping + glob-resolution logic mirrors `packages/cli/src/commands/cache.ts`
5+
* (`aggregateNamespaces` / `resolveCachePattern`). It is copied here rather than
6+
* imported because `@openmapx/cli` exposes no library entry point (only a `bin`)
7+
* and pulls in `commander`/docker helpers the API must not depend on. Keep the
8+
* two in sync: if the app adds a cache prefix, both must learn it.
9+
*/
10+
11+
/**
12+
* App-owned Redis key prefixes. `int:*` = every integration's `ctx.cache`
13+
* writes; `cache:*` = the API's own `withCache` keys. The cache admin endpoint
14+
* only ever scans/clears these prefixes — it must never touch the rest of a
15+
* (possibly shared) Redis database.
16+
*/
17+
export const APP_CACHE_PREFIXES = ["int:", "cache:"] as const;
18+
19+
/**
20+
* Turn a `cache clear` target into a Redis key glob. A bare word is treated as
21+
* an integration namespace (`geocoding` → `int:geocoding:*`); anything already
22+
* containing a `*` is passed through verbatim.
23+
*/
24+
export function resolveCachePattern(target: string): string {
25+
return target.includes("*") ? target : `int:${target}:*`;
26+
}
27+
28+
/**
29+
* Group Redis keys by namespace (everything up to the last `:` segment) with a
30+
* count each, sorted by count desc then name.
31+
*/
32+
export function aggregateNamespaces(keys: string[]): Array<{ namespace: string; count: number }> {
33+
const counts = new Map<string, number>();
34+
for (const key of keys) {
35+
const idx = key.lastIndexOf(":");
36+
const namespace = idx === -1 ? key : key.slice(0, idx);
37+
counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
38+
}
39+
return [...counts.entries()]
40+
.map(([namespace, count]) => ({ namespace, count }))
41+
.sort((a, b) => b.count - a.count || a.namespace.localeCompare(b.namespace));
42+
}
43+
44+
/** True when a resolved glob targets one of the app-owned cache prefixes. */
45+
export function isAppCachePattern(pattern: string): boolean {
46+
return APP_CACHE_PREFIXES.some((prefix) => pattern.startsWith(prefix));
47+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { Metadata } from "next";
2+
import { CacheManager } from "@/components/admin/cache/CacheManager";
3+
4+
export const metadata: Metadata = { title: "Cache — Admin — OpenMapX" };
5+
6+
export default function AdminCachePage() {
7+
return <CacheManager />;
8+
}

0 commit comments

Comments
 (0)