diff --git a/app/api/debug/profile-cache/route.ts b/app/api/debug/profile-cache/route.ts
new file mode 100644
index 00000000..084d30e6
--- /dev/null
+++ b/app/api/debug/profile-cache/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest } from "next/server";
+import {
+ debugFetchProfiles,
+ debugReadCache,
+ type DebugCacheEntry,
+ type Profile,
+} from "src/identity/profileCache";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// GET /api/debug/profile-cache?dids=did:plc:foo,did:plc:bar
+// Reports what's currently in Redis for each DID and what the upstream
+// Bluesky getProfiles call returns right now. Does not write to the cache.
+export async function GET(req: NextRequest) {
+ const param = req.nextUrl.searchParams.get("dids");
+ if (!param) {
+ return Response.json(
+ { error: "`dids` query param required (comma-separated)" },
+ { status: 400 },
+ );
+ }
+
+ const dids = Array.from(
+ new Set(
+ param
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ ),
+ );
+ if (dids.length === 0) {
+ return Response.json({ error: "no DIDs provided" }, { status: 400 });
+ }
+
+ const [cacheEntries, fetched] = await Promise.all([
+ debugReadCache(dids),
+ debugFetchProfiles(dids),
+ ]);
+
+ const result = dids.map((did) => {
+ const cache: DebugCacheEntry = cacheEntries.get(did) ?? { status: "miss" };
+ const live: Profile | null = fetched.get(did) ?? null;
+ return { did, cache, live };
+ });
+
+ return Response.json(
+ { dids, result },
+ { headers: { "Cache-Control": "no-store" } },
+ );
+}
diff --git a/app/debug/profile-cache/page.tsx b/app/debug/profile-cache/page.tsx
new file mode 100644
index 00000000..c3911837
--- /dev/null
+++ b/app/debug/profile-cache/page.tsx
@@ -0,0 +1,199 @@
+import {
+ debugFetchProfiles,
+ debugReadCache,
+ type DebugCacheEntry,
+ type Profile,
+} from "src/identity/profileCache";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+type SearchParams = Promise<{ dids?: string }>;
+
+export default async function ProfileCacheDebugPage({
+ searchParams,
+}: {
+ searchParams: SearchParams;
+}) {
+ const { dids: didsParam } = await searchParams;
+ const dids = parseDids(didsParam);
+
+ let rows: {
+ did: string;
+ cache: DebugCacheEntry;
+ live: Profile | null;
+ }[] = [];
+
+ if (dids.length > 0) {
+ const [cacheEntries, fetched] = await Promise.all([
+ debugReadCache(dids),
+ debugFetchProfiles(dids),
+ ]);
+ rows = dids.map((did) => ({
+ did,
+ cache: cacheEntries.get(did) ?? { status: "miss" },
+ live: fetched.get(did) ?? null,
+ }));
+ }
+
+ return (
+
+
Profile Cache Debug
+
+ Shows what's in the Redis cache and what bsky's
+ getProfiles returns right now. The cache is not updated.
+
+
+
+
+ {dids.length === 0 ? (
+
+ Enter one or more DIDs above to inspect.
+
+ ) : (
+
+ {rows.map((row) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ProfileRow(props: {
+ did: string;
+ cache: DebugCacheEntry;
+ live: Profile | null;
+}) {
+ return (
+
+
{props.did}
+
+
+
+ {JSON.stringify(props.cache, null, 2)}
+
+
+
+
+ {JSON.stringify(props.live, null, 2)}
+
+
+
+
+
+ );
+}
+
+function Pane({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
{title}
+
+ {children}
+
+
+ );
+}
+
+function cacheLabel(entry: DebugCacheEntry) {
+ if (entry.status !== "hit") return entry.status;
+ const ageSec = Math.round(entry.ageMs / 1000);
+ return entry.expired
+ ? `hit (expired, age ${ageSec}s)`
+ : `hit (age ${ageSec}s)`;
+}
+
+function DiffSummary({
+ cache,
+ live,
+}: {
+ cache: DebugCacheEntry;
+ live: Profile | null;
+}) {
+ if (cache.status !== "hit") {
+ return (
+
+ Nothing in cache to compare.
+
+ );
+ }
+ const cached = cache.profile;
+ if (!cached && !live) {
+ return (
+
+ Both cache and live are null (negative cache hit).
+
+ );
+ }
+ if (!cached || !live) {
+ return (
+
+ Mismatch: one side is null ({!cached ? "cache" : "live"}).
+
+ );
+ }
+ const fields: (keyof Profile)[] = [
+ "handle",
+ "displayName",
+ "avatar",
+ "description",
+ ];
+ const diffs = fields.filter((f) => cached[f] !== live[f]);
+ if (diffs.length === 0) {
+ return (
+
+ Cache matches live for handle/displayName/avatar/description.
+
+ );
+ }
+ return (
+
+
Differs on: {diffs.join(", ")}
+ {diffs.map((f) => (
+
+ {f}:{" "}
+ cache={JSON.stringify(cached[f])}{" "}
+ live={JSON.stringify(live[f])}
+
+ ))}
+
+ );
+}
+
+function parseDids(raw: string | undefined): string[] {
+ if (!raw) return [];
+ return Array.from(
+ new Set(
+ raw
+ .split(/[\s,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean),
+ ),
+ );
+}
diff --git a/src/identity/profileCache.ts b/src/identity/profileCache.ts
index 31c81c6b..aea77779 100644
--- a/src/identity/profileCache.ts
+++ b/src/identity/profileCache.ts
@@ -105,3 +105,50 @@ export const getProfiles = cache(
return result;
},
);
+
+export type DebugCacheEntry =
+ | { status: "hit"; profile: Profile | null; updatedAt: number; ageMs: number; expired: boolean }
+ | { status: "miss" }
+ | { status: "malformed"; raw: string }
+ | { status: "disabled" };
+
+export async function debugReadCache(
+ dids: string[],
+): Promise