···11-ALTER TABLE `feature_requests` RENAME COLUMN "label_tid" TO "label_updated_at";--> statement-breakpoint
22-ALTER TABLE `kanban_tasks` RENAME COLUMN "label_tid" TO "label_updated_at";--> statement-breakpoint
33-CREATE TABLE `label_pds_records` (
44- `entity_id` text NOT NULL,
55- `entity_type` text NOT NULL,
66- `actor_did` text NOT NULL,
77- `rkey` text NOT NULL,
88- PRIMARY KEY(`entity_id`, `entity_type`, `actor_did`)
99-);
1010---> statement-breakpoint
1111-ALTER TABLE `kanban_columns` ADD `status_type` text DEFAULT 'backlog' NOT NULL;11+-- No-op: changes captured here were already applied by 0007 and 0008.
22+-- Kept alongside its snapshot so drizzle-kit has a valid baseline for future migrations.
33+SELECT 1;
+5
drizzle/0010_dark_red_skull.sql
···11+CREATE TABLE `did_handles` (
22+ `did` text PRIMARY KEY NOT NULL,
33+ `handle` text NOT NULL,
44+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
55+);
···11import { Hono } from "hono";
22import { serveStatic } from "hono/bun";
33import { getCookie } from "hono/cookie";
44-import { getOAuthClient, oauthRoutes } from "@exosphere/core/auth";
44+import { getOAuthClient, oauthRoutes, resolveAuthenticatedHandle } from "@exosphere/core/auth";
55+import { handlesApi } from "@exosphere/core/identity";
56import { createSphereRoutes, getCurrentSphere, sphereContext } from "@exosphere/core/sphere";
67import { isMultiSphere } from "@exosphere/core/config";
78import { startJetstream, stopCursorFlushing } from "@exosphere/indexer";
···16171718// Mount OAuth routes
1819app.route("/api/oauth", oauthRoutes);
2020+2121+// Mount batch DID → handle resolver
2222+app.route("/api/handles", handlesApi);
19232024// Register modules — sphere context is injected by middleware in both modes
2125if (isMultiSphere) {
···116120 try {
117121 const client = await getOAuthClient();
118122 const session = await client.restore(sid);
119119- let handle: string | null = null;
120120- try {
121121- const res = await session.fetchHandler(
122122- `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`,
123123- );
124124- if (res.ok) {
125125- const repo = (await res.json()) as { handle: string };
126126- handle = repo.handle;
127127- }
128128- } catch {}
123123+ const handle = await resolveAuthenticatedHandle(session);
129124 authData = { authenticated: true, did: session.did, handle };
130125 } catch {
131126 /* invalid session */
+12-8
packages/app/src/ssr-prefetch-orchestrator.ts
···45454646 if (hasSphere) {
4747 const prefetches = ssrPrefetch(url, sphereHandle);
4848- for (const prefetch of prefetches) {
4949- try {
5050- const res = await fetchApi(prefetch.apiUrl);
5151- if (res.ok) pageData[prefetch.key] = await res.json();
5252- } catch {
5353- /* prefetch failed — client will fetch */
5454- }
5555- }
4848+ // Sibling prefetches are independent — fan them out in parallel so the
4949+ // slowest one determines the latency, not the sum.
5050+ await Promise.all(
5151+ prefetches.map(async (prefetch) => {
5252+ try {
5353+ const res = await fetchApi(prefetch.apiUrl);
5454+ if (res.ok) pageData[prefetch.key] = await res.json();
5555+ } catch {
5656+ /* prefetch failed — client will fetch */
5757+ }
5858+ }),
5959+ );
56605761 const apiBase = sphereHandle ? `/api/s/${sphereHandle}` : "/api";
5862
+64
packages/core/src/auth/handle.ts
···11+import { getCachedDidHandle, setDidHandle } from "../identity/index.ts";
22+33+// Minimal shape of what we need from an OAuth session — `fetchHandler` is
44+// authenticated + bound to the user's PDS, which is the authoritative source
55+// for their handle. We prefer it over the PLC directory for the user's own DID.
66+export interface AuthenticatedSession {
77+ did: string;
88+ fetchHandler: (url: string) => Promise<Response>;
99+}
1010+1111+export async function describeRepoHandle(session: AuthenticatedSession): Promise<string | null> {
1212+ try {
1313+ const res = await session.fetchHandler(
1414+ `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`,
1515+ );
1616+ if (res.ok) {
1717+ const repo = (await res.json()) as { handle: string };
1818+ // Normalize empty/missing handles to null so callers don't render "@".
1919+ return repo.handle || null;
2020+ }
2121+ } catch {}
2222+ return null;
2323+}
2424+2525+// Coalesce concurrent describeRepo refreshes for the same DID — two SSRs
2626+// landing on a cold/stale entry simultaneously must not double-hit the PDS.
2727+const inFlightRefresh = new Map<string, Promise<string | null>>();
2828+2929+/**
3030+ * PDS-authoritative refresh for an authenticated user's own handle: calls
3131+ * `describeRepo` and persists to the cache. Concurrent calls for the same DID
3232+ * share a single in-flight promise.
3333+ */
3434+export function refreshAuthenticatedHandle(session: AuthenticatedSession): Promise<string | null> {
3535+ const pending = inFlightRefresh.get(session.did);
3636+ if (pending) return pending;
3737+ const p = describeRepoHandle(session)
3838+ .then((fresh) => {
3939+ if (fresh) setDidHandle(session.did, fresh);
4040+ return fresh;
4141+ })
4242+ .finally(() => inFlightRefresh.delete(session.did));
4343+ inFlightRefresh.set(session.did, p);
4444+ return p;
4545+}
4646+4747+/**
4848+ * Resolve the authenticated user's handle — cache-first with PDS fallback.
4949+ * - Cache hit (fresh): return immediately.
5050+ * - Cache hit (stale): return cached value, refresh via PDS in the background.
5151+ * - Cache miss: block on PDS `describeRepo`, persist, return.
5252+ */
5353+export async function resolveAuthenticatedHandle(
5454+ session: AuthenticatedSession,
5555+): Promise<string | null> {
5656+ const cached = getCachedDidHandle(session.did);
5757+ if (cached) {
5858+ if (cached.stale) {
5959+ refreshAuthenticatedHandle(session).catch(() => {});
6060+ }
6161+ return cached.handle;
6262+ }
6363+ return refreshAuthenticatedHandle(session);
6464+}
+6
packages/core/src/auth/index.ts
···22export { oauthRoutes } from "./routes.ts";
33export { requireAuth, optionalAuth } from "./middleware.ts";
44export type { AuthEnv } from "./middleware.ts";
55+export {
66+ describeRepoHandle,
77+ refreshAuthenticatedHandle,
88+ resolveAuthenticatedHandle,
99+} from "./handle.ts";
1010+export type { AuthenticatedSession } from "./handle.ts";
+8-10
packages/core/src/auth/routes.ts
···11import { Hono } from "hono";
22import { setCookie, getCookie, deleteCookie } from "hono/cookie";
33import { getOAuthClient, resolveHandle, rewritePdsUrl } from "./client.ts";
44+import { refreshAuthenticatedHandle, resolveAuthenticatedHandle } from "./handle.ts";
4556const auth = new Hono();
67···5051 maxAge: 60 * 60 * 24 * 30, // 30 days
5152 });
52535454+ // Force-refresh the user's handle in the cache on every signin. Gives
5555+ // users a way to pull a renamed handle through without waiting for TTL.
5656+ refreshAuthenticatedHandle(session).catch(() => {
5757+ /* defensive — refreshAuthenticatedHandle already swallows internally */
5858+ });
5959+5360 return c.redirect("/");
5461 } catch (err) {
5562 console.error("[oauth/callback] error:", err instanceof Error ? err.message : err);
···6774 try {
6875 const client = await getOAuthClient();
6976 const session = await client.restore(did);
7070- let handle: string | undefined;
7171- try {
7272- const res = await session.fetchHandler(
7373- `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(session.did)}`,
7474- );
7575- if (res.ok) {
7676- const repo = (await res.json()) as { handle: string };
7777- handle = repo.handle;
7878- }
7979- } catch {}
7777+ const handle = (await resolveAuthenticatedHandle(session)) ?? undefined;
8078 return c.json({ authenticated: true, did: session.did, handle });
8179 } catch {
8280 deleteCookie(c, "sid", { path: "/" });
+12
packages/core/src/db/schema/identity.ts
···11+import { sqliteTable, text } from "drizzle-orm/sqlite-core";
22+import { sql } from "drizzle-orm";
33+44+// Persistent DID → handle cache. Read synchronously on SSR paths; refreshed
55+// lazily via stale-while-revalidate by `resolveDidHandles`.
66+export const didHandles = sqliteTable("did_handles", {
77+ did: text("did").primaryKey(),
88+ handle: text("handle").notNull(),
99+ updatedAt: text("updated_at")
1010+ .notNull()
1111+ .default(sql`(datetime('now'))`),
1212+});
+1
packages/core/src/db/schema/index.ts
···55export { sphereEntryCounter } from "./entry-counter.ts";
66export { sphereLabels, entityLabels, labelPdsRecords } from "./labels.ts";
77export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts";
88+export { didHandles } from "./identity.ts";
+8-1
packages/core/src/identity/index.ts
···11-export { resolveDidHandles } from "./resolve-handles.ts";
11+export {
22+ resolveDidHandles,
33+ getCachedDidHandle,
44+ warmDidHandles,
55+ setDidHandle,
66+ collectCachedHandles,
77+} from "./resolve-handles.ts";
88+export { handlesApi } from "./routes.ts";
+183-28
packages/core/src/identity/resolve-handles.ts
···11+import { eq } from "drizzle-orm";
22+import { getDb } from "../db/index.ts";
33+import { didHandles } from "../db/schema/identity.ts";
44+15const pdsUrl = process.env.PDS_URL?.replace(/\/$/, "");
2633-/** Simple TTL cache for DID → handle mappings. */
44-const cache = new Map<string, { handle: string; expiresAt: number }>();
55-const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
77+// Short TTL — handles rarely change, but when they do we want the UI to catch
88+// up within a day or two. Stale entries are served optimistically and
99+// revalidated in the background (stale-while-revalidate).
1010+const TTL_MS = 24 * 60 * 60 * 1000; // 24h
1111+1212+// Soft cap on the in-memory cache so a long-running process doesn't grow the
1313+// map unbounded. The DB is the source of truth — eviction only costs an extra
1414+// DB read the next time a victim DID is requested.
1515+const MAX_MEM_ENTRIES = 5000;
1616+1717+interface CachedEntry {
1818+ handle: string;
1919+ updatedAtMs: number;
2020+}
2121+2222+// In-memory layer in front of the DB for hot-path reuse within a process.
2323+// Map preserves insertion order — we bump entries to the end on every read or
2424+// write to get LRU semantics; `.keys().next().value` then drops the true
2525+// least-recently-used entry on eviction.
2626+const memCache = new Map<string, CachedEntry>();
2727+2828+function touchMemCache(did: string, entry: CachedEntry): void {
2929+ memCache.delete(did);
3030+ memCache.set(did, entry);
3131+}
3232+3333+function evictIfNeeded(): void {
3434+ while (memCache.size > MAX_MEM_ENTRIES) {
3535+ const oldest = memCache.keys().next().value;
3636+ if (oldest === undefined) break;
3737+ memCache.delete(oldest);
3838+ }
3939+}
4040+4141+// SQLite's `datetime('now')` default yields "YYYY-MM-DD HH:MM:SS" (UTC, no
4242+// timezone marker). Our own writes produce full ISO with `Z`. Normalize so
4343+// Date.parse reliably reads both — defensive against external writers.
4444+function parseTimestamp(s: string): number {
4545+ const hasTimezone = /[Zz]|[+-]\d\d:?\d\d$/.test(s);
4646+ return Date.parse(hasTimezone ? s : s.replace(" ", "T") + "Z");
4747+}
4848+4949+// Coalesce concurrent resolutions for the same DID into one network fetch.
5050+const inFlight = new Map<string, Promise<string | null>>();
5151+5252+function isStale(updatedAtMs: number): boolean {
5353+ return Date.now() - updatedAtMs > TTL_MS;
5454+}
5555+5656+function writeCache(did: string, handle: string, updatedAtMs: number): void {
5757+ touchMemCache(did, { handle, updatedAtMs });
5858+ evictIfNeeded();
5959+ try {
6060+ const iso = new Date(updatedAtMs).toISOString();
6161+ getDb()
6262+ .insert(didHandles)
6363+ .values({ did, handle, updatedAt: iso })
6464+ .onConflictDoUpdate({
6565+ target: didHandles.did,
6666+ set: { handle, updatedAt: iso },
6767+ })
6868+ .run();
6969+ } catch {
7070+ // DB write failing must not break the in-memory cache or the caller.
7171+ }
7272+}
673774/**
88- * Resolve a single DID to its AT Protocol handle.
99- * In local dev (PDS_URL set), queries the local PDS.
1010- * In production, resolves via the PLC directory.
7575+ * Synchronous peek across memory + DB. Returns the cached handle even when
7676+ * stale — callers are expected to kick off a background refresh (see
7777+ * {@link warmDidHandles}) so the next request serves fresh data. Returns
7878+ * `null` only for DIDs we have never resolved.
1179 */
1212-async function resolveSingle(did: string): Promise<string | null> {
1313- const cached = cache.get(did);
1414- if (cached && cached.expiresAt > Date.now()) return cached.handle;
8080+export function getCachedDidHandle(did: string): { handle: string; stale: boolean } | null {
8181+ if (!did) return null;
15821616- const handle = pdsUrl ? await resolveViaPds(did) : await resolveViaPlcDirectory(did);
8383+ const mem = memCache.get(did);
8484+ if (mem) {
8585+ touchMemCache(did, mem);
8686+ return { handle: mem.handle, stale: isStale(mem.updatedAtMs) };
8787+ }
17881818- if (handle) {
1919- cache.set(did, { handle, expiresAt: Date.now() + CACHE_TTL_MS });
8989+ let row: { handle: string; updatedAt: string } | undefined;
9090+ try {
9191+ row = getDb()
9292+ .select({ handle: didHandles.handle, updatedAt: didHandles.updatedAt })
9393+ .from(didHandles)
9494+ .where(eq(didHandles.did, did))
9595+ .get();
9696+ } catch (err) {
9797+ // Graceful degrade: missing table / DB outage falls through to the network
9898+ // resolve path. Surface the cause so prod issues aren't silent.
9999+ console.error("[identity] did_handles read failed:", err);
100100+ return null;
20101 }
2121- return handle;
102102+ if (!row) return null;
103103+104104+ const updatedAtMs = parseTimestamp(row.updatedAt);
105105+ // memCache was just confirmed empty for this DID — direct insert, no delete.
106106+ memCache.set(did, { handle: row.handle, updatedAtMs });
107107+ evictIfNeeded();
108108+ return { handle: row.handle, stale: isStale(updatedAtMs) };
22109}
2311024111async function resolveViaPds(did: string): Promise<string | null> {
···46133 return null;
47134}
48135136136+async function fetchAndStore(did: string): Promise<string | null> {
137137+ const handle = pdsUrl ? await resolveViaPds(did) : await resolveViaPlcDirectory(did);
138138+ if (!handle) return null;
139139+ writeCache(did, handle, Date.now());
140140+ return handle;
141141+}
142142+143143+function resolveSingle(did: string): Promise<string | null> {
144144+ const pending = inFlight.get(did);
145145+ if (pending) return pending;
146146+ const p = fetchAndStore(did).finally(() => inFlight.delete(did));
147147+ inFlight.set(did, p);
148148+ return p;
149149+}
150150+49151/**
5050- * Batch-resolve an array of DIDs to handles.
5151- * Returns a Map<did, handle>. DIDs that cannot be resolved are omitted.
152152+ * Write a freshly-resolved DID → handle into the cache. Used by the auth flow
153153+ * to force-refresh a user's own handle on signin, bypassing the TTL.
154154+ */
155155+export function setDidHandle(did: string, handle: string): void {
156156+ if (!did || !handle) return;
157157+ writeCache(did, handle, Date.now());
158158+}
159159+160160+/**
161161+ * Single pass over a row set: returns a `did → handle` map for the cached
162162+ * entries (fresh + stale) and the list of DIDs that need a background refresh
163163+ * (missing or stale). Used by SSR endpoints that want one `getCachedDidHandle`
164164+ * call per DID.
165165+ */
166166+export function collectCachedHandles<T>(
167167+ rows: readonly T[],
168168+ getDid: (row: T) => string,
169169+): { handles: Map<string, string>; toWarm: string[] } {
170170+ const handles = new Map<string, string>();
171171+ const toWarm: string[] = [];
172172+ for (const row of rows) {
173173+ const did = getDid(row);
174174+ if (!did) continue;
175175+ const cached = getCachedDidHandle(did);
176176+ if (cached) {
177177+ handles.set(did, cached.handle);
178178+ if (cached.stale) toWarm.push(did);
179179+ } else {
180180+ toWarm.push(did);
181181+ }
182182+ }
183183+ return { handles, toWarm };
184184+}
185185+186186+/**
187187+ * Kick off background refreshes for DIDs that are missing or stale. Never
188188+ * throws, never waits — safe to call from the SSR response path.
189189+ */
190190+export function warmDidHandles(dids: Iterable<string>): void {
191191+ for (const did of new Set(dids)) {
192192+ if (!did) continue;
193193+ const cached = getCachedDidHandle(did);
194194+ if (cached && !cached.stale) continue;
195195+ resolveSingle(did).catch(() => {});
196196+ }
197197+}
198198+199199+/**
200200+ * Batch-resolve DIDs to handles. Returns stale cached values immediately (and
201201+ * warms them in the background); awaits network resolution only for DIDs we
202202+ * have never seen.
52203 */
53204export async function resolveDidHandles(dids: string[]): Promise<Map<string, string>> {
5454- const unique = [...new Set(dids)];
205205+ const unique = [...new Set(dids)].filter((d) => d.length > 0);
55206 const results = new Map<string, string>();
5656- const toResolve: string[] = [];
207207+ const toFetch: string[] = [];
208208+ const toRevalidate: string[] = [];
572095858- // Check cache first
59210 for (const did of unique) {
6060- const cached = cache.get(did);
6161- if (cached && cached.expiresAt > Date.now()) {
211211+ const cached = getCachedDidHandle(did);
212212+ if (cached) {
62213 results.set(did, cached.handle);
214214+ if (cached.stale) toRevalidate.push(did);
63215 } else {
6464- toResolve.push(did);
216216+ toFetch.push(did);
65217 }
66218 }
672196868- // Resolve remaining in parallel
6969- if (toResolve.length > 0) {
7070- const settled = await Promise.allSettled(toResolve.map((did) => resolveSingle(did)));
7171- for (let i = 0; i < toResolve.length; i++) {
7272- const result = settled[i];
7373- if (result.status === "fulfilled" && result.value) {
7474- results.set(toResolve[i], result.value);
220220+ for (const did of toRevalidate) {
221221+ resolveSingle(did).catch(() => {});
222222+ }
223223+224224+ if (toFetch.length > 0) {
225225+ const settled = await Promise.allSettled(toFetch.map((d) => resolveSingle(d)));
226226+ for (let i = 0; i < toFetch.length; i++) {
227227+ const r = settled[i];
228228+ if (r.status === "fulfilled" && r.value) {
229229+ results.set(toFetch[i], r.value);
75230 }
76231 }
77232 }
+26
packages/core/src/identity/routes.ts
···11+import { Hono } from "hono";
22+import { resolveDidHandles } from "./resolve-handles.ts";
33+44+const MAX_DIDS_PER_REQUEST = 50;
55+66+const app = new Hono();
77+88+// Batch DID → handle resolution. Clients call this after hydration to keep
99+// SSR off the external PLC directory path.
1010+app.get("/", async (c) => {
1111+ const raw = c.req.query("dids") ?? "";
1212+ const dids = raw
1313+ .split(",")
1414+ .map((d) => d.trim())
1515+ .filter((d) => d.length > 0)
1616+ .slice(0, MAX_DIDS_PER_REQUEST);
1717+1818+ if (dids.length === 0) return c.json({ handles: {} });
1919+2020+ const handleMap = await resolveDidHandles(dids);
2121+ const handles: Record<string, string> = {};
2222+ for (const [did, handle] of handleMap) handles[did] = handle;
2323+ return c.json({ handles });
2424+});
2525+2626+export { app as handlesApi };