···9292 return did, nil
9393}
94949595+// InvalidateHandle removes a handle from the resolver cache so the next
9696+// ResolveHandle call refetches from the directory. Called when a firehose
9797+// identity event signals that a handle's DID mapping has changed.
9898+func (c *PublicClient) InvalidateHandle(handle string) {
9999+ if handle == "" {
100100+ return
101101+ }
102102+ c.handleMu.Lock()
103103+ delete(c.handleCache, handle)
104104+ c.handleMu.Unlock()
105105+}
106106+107107+// InvalidateDID drops any cached entries pointing at this DID — both the
108108+// PDS endpoint cache and any handle→DID mappings whose resolved DID is the
109109+// given one. Used when a DID's repo is gone (account deleted/takendown) or
110110+// when a handle has been reassigned away from this DID.
111111+func (c *PublicClient) InvalidateDID(did string) {
112112+ if did == "" {
113113+ return
114114+ }
115115+ c.pdsMu.Lock()
116116+ delete(c.pdsCache, did)
117117+ c.pdsMu.Unlock()
118118+119119+ c.handleMu.Lock()
120120+ for h, v := range c.handleCache {
121121+ if v.value == did {
122122+ delete(c.handleCache, h)
123123+ }
124124+ }
125125+ c.handleMu.Unlock()
126126+}
127127+95128// PublicListRecordsOutput represents the response from public listRecords API.
96129type PublicListRecordsOutput struct {
97130 Records []PublicRecordEntry `json:"records"`
+143-39
internal/firehose/index.go
···128128 expires_at TEXT NOT NULL
129129);
130130131131+CREATE TABLE IF NOT EXISTS did_by_handle (
132132+ handle TEXT PRIMARY KEY,
133133+ did TEXT NOT NULL,
134134+ updated_at TEXT NOT NULL
135135+);
136136+CREATE INDEX IF NOT EXISTS idx_did_by_handle_did ON did_by_handle(did);
137137+131138CREATE TABLE IF NOT EXISTS likes (
132139 subject_uri TEXT NOT NULL,
133140 actor_did TEXT NOT NULL,
···289296 profileCache: make(map[string]*CachedProfile),
290297 }
291298299299+ // One-time backfill: populate did_by_handle from any pre-existing profile rows
300300+ // so handle resolution works for users observed before this table existed.
301301+ if err := idx.backfillHandleIndex(); err != nil {
302302+ log.Warn().Err(err).Msg("did_by_handle backfill failed; lookups will populate lazily")
303303+ }
304304+292305 // If the database already has records from a previous run, mark ready immediately
293306 // so the feed is served from persisted data while the firehose reconnects.
294307 var count int
···299312 return idx, nil
300313}
301314315315+// backfillHandleIndex populates did_by_handle from the profiles table. Idempotent.
316316+// Iterates every cached profile and inserts (handle, did) — last writer wins,
317317+// matching the live storeProfile semantics, so a handle that existed on multiple
318318+// DIDs resolves to whichever profile was inserted most recently in the iteration.
319319+func (idx *FeedIndex) backfillHandleIndex() error {
320320+ var n int
321321+ if err := idx.db.QueryRow(`SELECT COUNT(*) FROM did_by_handle`).Scan(&n); err == nil && n > 0 {
322322+ return nil
323323+ }
324324+325325+ rows, err := idx.db.Query(`SELECT did, data FROM profiles`)
326326+ if err != nil {
327327+ return err
328328+ }
329329+ defer rows.Close()
330330+331331+ now := time.Now().Format(time.RFC3339Nano)
332332+ for rows.Next() {
333333+ var did, dataStr string
334334+ if err := rows.Scan(&did, &dataStr); err != nil {
335335+ continue
336336+ }
337337+ cached := &CachedProfile{}
338338+ if err := json.Unmarshal([]byte(dataStr), cached); err != nil || cached.Profile == nil || cached.Profile.Handle == "" {
339339+ continue
340340+ }
341341+ _, _ = idx.db.Exec(
342342+ `INSERT OR REPLACE INTO did_by_handle (handle, did, updated_at) VALUES (?, ?, ?)`,
343343+ cached.Profile.Handle, did, now)
344344+ }
345345+ return rows.Err()
346346+}
347347+302348// Compile-time check: FeedIndex must satisfy the atproto.WitnessCache interface.
303349var _ atproto.WitnessCache = (*FeedIndex)(nil)
304350···490536 {`DELETE FROM notifications WHERE target_did = ? OR actor_did = ?`, []any{did, did}},
491537 {`DELETE FROM notifications_meta WHERE target_did = ?`, []any{did}},
492538 {`DELETE FROM profiles WHERE did = ?`, []any{did}},
539539+ {`DELETE FROM did_by_handle WHERE did = ?`, []any{did}},
493540 {`DELETE FROM known_dids WHERE did = ?`, []any{did}},
494541 {`DELETE FROM registered_dids WHERE did = ?`, []any{did}},
495542 {`DELETE FROM backfilled WHERE did = ?`, []any{did}},
···11241171 return profile, nil
11251172}
1126117311271127-// storeProfile writes a profile to both in-memory and persistent caches.
11741174+// storeProfile writes a profile to both in-memory and persistent caches, and
11751175+// maintains the did_by_handle index so handle lookups stay accurate across
11761176+// handle changes and handle reassignment between DIDs.
11281177func (idx *FeedIndex) storeProfile(ctx context.Context, did string, profile *atproto.Profile) {
11291178 now := time.Now()
11301179 cached := &CachedProfile{
···11401189 data, _ := json.Marshal(cached)
11411190 _, _ = idx.db.ExecContext(ctx, `INSERT OR REPLACE INTO profiles (did, data, expires_at) VALUES (?, ?, ?)`,
11421191 did, string(data), cached.ExpiresAt.Format(time.RFC3339Nano))
11431143-}
1144119211451145-// GetDIDByHandle looks up a DID from the profile cache by handle.
11461146-// Returns the DID and true if found, or empty string and false if not cached.
11471147-// This avoids a ResolveHandle API call for known Arabica users.
11481148-func (idx *FeedIndex) GetDIDByHandle(ctx context.Context, handle string) (string, bool) {
11491149- // Check in-memory cache first
11501150- idx.profileCacheMu.RLock()
11511151- for did, cached := range idx.profileCache {
11521152- if cached.Profile != nil && cached.Profile.Handle == handle && time.Now().Before(cached.ExpiresAt) {
11531153- idx.profileCacheMu.RUnlock()
11541154- return did, true
11551155- }
11931193+ if profile != nil && profile.Handle != "" {
11941194+ // Drop any prior row pointing this DID at a different handle (handle change).
11951195+ _, _ = idx.db.ExecContext(ctx,
11961196+ `DELETE FROM did_by_handle WHERE did = ? AND handle != ?`, did, profile.Handle)
11971197+ // Last writer wins on handle — this naturally resolves handle reassignment
11981198+ // from an old DID to a new one, since the new profile's INSERT OR REPLACE
11991199+ // overwrites the old DID's mapping.
12001200+ _, _ = idx.db.ExecContext(ctx,
12011201+ `INSERT OR REPLACE INTO did_by_handle (handle, did, updated_at) VALUES (?, ?, ?)`,
12021202+ profile.Handle, did, now.Format(time.RFC3339Nano))
11561203 }
11571157- idx.profileCacheMu.RUnlock()
12041204+}
1158120511591159- // Check persistent store
11601160- rows, err := idx.db.QueryContext(ctx, `SELECT did, data FROM profiles`)
11611161- if err != nil {
12061206+// GetDIDByHandle looks up a DID from the handle index. Returns the DID and
12071207+// true if found, or empty string and false if not indexed.
12081208+//
12091209+// Backed by the did_by_handle table — last-writer-wins, so a handle that has
12101210+// been reassigned to a new DID resolves to that new DID once the new profile
12111211+// is observed (via the firehose profile watcher or a GetProfile call).
12121212+func (idx *FeedIndex) GetDIDByHandle(ctx context.Context, handle string) (string, bool) {
12131213+ var did string
12141214+ err := idx.db.QueryRowContext(ctx,
12151215+ `SELECT did FROM did_by_handle WHERE handle = ?`, handle).Scan(&did)
12161216+ if err != nil || did == "" {
11621217 return "", false
11631218 }
11641164- defer rows.Close()
11651165-11661166- for rows.Next() {
11671167- var did, dataStr string
11681168- if err := rows.Scan(&did, &dataStr); err != nil {
11691169- continue
11701170- }
11711171- cached := &CachedProfile{}
11721172- if err := json.Unmarshal([]byte(dataStr), cached); err != nil || cached.Profile == nil {
11731173- continue
11741174- }
11751175- if cached.Profile.Handle == handle {
11761176- // Promote to in-memory cache
11771177- cached.ExpiresAt = time.Now().Add(idx.profileTTL)
11781178- idx.profileCacheMu.Lock()
11791179- idx.profileCache[did] = cached
11801180- idx.profileCacheMu.Unlock()
11811181- return did, true
11821182- }
11831183- }
11841184-11851185- return "", false
12191219+ return did, true
11861220}
1187122111881222// InvalidateProfile removes a DID's profile from both the in-memory and persistent
···11931227 idx.profileCacheMu.Unlock()
1194122811951229 _, _ = idx.db.Exec(`DELETE FROM profiles WHERE did = ?`, did)
12301230+ _, _ = idx.db.Exec(`DELETE FROM did_by_handle WHERE did = ?`, did)
11961231}
1197123211981233// RefreshProfile fetches a profile from the API and stores it in both caches.
···12061241 }
1207124212081243 idx.storeProfile(ctx, did, profile)
12441244+}
12451245+12461246+// InvalidatePublicCachesForDID drops the public client's cached PDS endpoint
12471247+// and any handle→DID mappings pointing at this DID. Used when an account is
12481248+// deleted/takendown so subsequent lookups don't keep hitting the tombstoned DID.
12491249+func (idx *FeedIndex) InvalidatePublicCachesForDID(did string) {
12501250+ if idx.publicClient != nil {
12511251+ idx.publicClient.InvalidateDID(did)
12521252+ }
12531253+}
12541254+12551255+// OnIdentityEvent reconciles caches when a Jetstream identity event reports
12561256+// that a DID's handle has changed. It is the only path through which a handle
12571257+// can be reassigned from one DID to another (handle release + reclaim by a
12581258+// different account), so this is where stale mappings must be evicted.
12591259+//
12601260+// Steps:
12611261+// 1. Look up this DID's previously cached handle (the old handle).
12621262+// 2. Find any *other* DID whose cached profile still claims the new handle —
12631263+// that's the prior owner; invalidate its profile and resolver entries.
12641264+// 3. Drop the old handle from the resolver cache (it may now resolve to
12651265+// someone else, or to nothing).
12661266+// 4. Drop the new handle from the resolver cache so the next ResolveHandle
12671267+// re-fetches from the directory.
12681268+// 5. Refresh this DID's profile via the API; storeProfile then writes the
12691269+// authoritative did_by_handle row.
12701270+func (idx *FeedIndex) OnIdentityEvent(ctx context.Context, did, newHandle string) {
12711271+ var oldHandle string
12721272+ idx.profileCacheMu.RLock()
12731273+ if cached, ok := idx.profileCache[did]; ok && cached.Profile != nil {
12741274+ oldHandle = cached.Profile.Handle
12751275+ }
12761276+ idx.profileCacheMu.RUnlock()
12771277+ if oldHandle == "" {
12781278+ // Fall back to persistent store.
12791279+ var dataStr string
12801280+ if err := idx.db.QueryRowContext(ctx, `SELECT data FROM profiles WHERE did = ?`, did).Scan(&dataStr); err == nil {
12811281+ cached := &CachedProfile{}
12821282+ if err := json.Unmarshal([]byte(dataStr), cached); err == nil && cached.Profile != nil {
12831283+ oldHandle = cached.Profile.Handle
12841284+ }
12851285+ }
12861286+ }
12871287+12881288+ if newHandle != "" {
12891289+ // Evict any prior owner of newHandle (other than `did` itself).
12901290+ var priorDID string
12911291+ err := idx.db.QueryRowContext(ctx,
12921292+ `SELECT did FROM did_by_handle WHERE handle = ? AND did != ?`, newHandle, did).Scan(&priorDID)
12931293+ if err == nil && priorDID != "" {
12941294+ log.Warn().
12951295+ Str("handle", newHandle).
12961296+ Str("prior_did", priorDID).
12971297+ Str("new_did", did).
12981298+ Msg("identity event: handle reassigned, invalidating prior owner")
12991299+ idx.InvalidateProfile(priorDID)
13001300+ idx.publicClient.InvalidateDID(priorDID)
13011301+ }
13021302+ }
13031303+13041304+ if oldHandle != "" && oldHandle != newHandle {
13051305+ idx.publicClient.InvalidateHandle(oldHandle)
13061306+ }
13071307+ if newHandle != "" {
13081308+ idx.publicClient.InvalidateHandle(newHandle)
13091309+ }
13101310+ idx.publicClient.InvalidateDID(did)
13111311+13121312+ idx.RefreshProfile(ctx, did)
12091313}
1210131412111315// GetKnownDIDs returns all DIDs that have created Arabica records
+7-5
internal/firehose/profile_watcher.go
···308308 }
309309310310 case "identity":
311311- // Handle change or PDS migration — refresh the cached profile so handle
312312- // resolution stays accurate. Profile-commit events don't fire on handle
313313- // changes, so this is the only signal we get.
314314- pw.index.RefreshProfile(context.Background(), event.DID)
311311+ // Handle change, handle reassignment, or PDS migration. OnIdentityEvent
312312+ // reconciles the profile cache, did_by_handle index, and resolver caches
313313+ // before refreshing — this is also the only signal we get when a handle
314314+ // moves from an abandoned DID to a new one.
315315 handle := ""
316316 if event.Identity != nil {
317317 handle = event.Identity.Handle
318318 }
319319- log.Info().Str("did", event.DID).Str("handle", handle).Msg("profile watcher: identity update, refreshed profile")
319319+ pw.index.OnIdentityEvent(context.Background(), event.DID, handle)
320320+ log.Info().Str("did", event.DID).Str("handle", handle).Msg("profile watcher: identity update reconciled")
320321321322 case "account":
322323 if event.Account == nil {
···336337 log.Error().Err(err).Str("did", event.DID).Str("status", status).Msg("profile watcher: failed to delete user data")
337338 return
338339 }
340340+ pw.index.InvalidatePublicCachesForDID(event.DID)
339341 log.Warn().Str("did", event.DID).Str("status", status).Msg("profile watcher: purged all data for account")
340342 pw.Unwatch(event.DID)
341343 }
+51
internal/handlers/admin.go
···791791 log.Error().Err(err).Str("did", didStr).Msg("witness export: encode failed")
792792 }
793793}
794794+795795+// HandleAdminPurgeDID removes every trace of a DID from the witness cache:
796796+// records, likes, comments (including ones targeting this DID's records),
797797+// notifications, profile cache, did_by_handle index, known/registered/backfilled
798798+// tracking, and user settings. Moderation tables are preserved as evidence.
799799+//
800800+// Required when an account orphans its data — e.g. the user's PDS goes away
801801+// without the firehose ever emitting a deleted/takendown account event, so the
802802+// stale records sit in the cache forever. Auth and admin checks are handled by
803803+// RequireAdmin.
804804+func (h *Handler) HandleAdminPurgeDID(w http.ResponseWriter, r *http.Request) {
805805+ rawDID := strings.TrimSpace(r.URL.Query().Get("did"))
806806+ if rawDID == "" {
807807+ // Form posts may put it in the body.
808808+ if err := r.ParseForm(); err == nil {
809809+ rawDID = strings.TrimSpace(r.FormValue("did"))
810810+ }
811811+ }
812812+ if rawDID == "" {
813813+ http.Error(w, "missing 'did' parameter", http.StatusBadRequest)
814814+ return
815815+ }
816816+ did, err := syntax.ParseDID(rawDID)
817817+ if err != nil {
818818+ http.Error(w, fmt.Sprintf("invalid DID: %v", err), http.StatusBadRequest)
819819+ return
820820+ }
821821+ if h.feedIndex == nil {
822822+ http.Error(w, "feed index not configured", http.StatusServiceUnavailable)
823823+ return
824824+ }
825825+826826+ didStr := did.String()
827827+ actor, _ := atproto.GetAuthenticatedDID(r.Context())
828828+829829+ if err := h.feedIndex.DeleteAllByDID(r.Context(), didStr); err != nil {
830830+ log.Error().Err(err).Str("did", didStr).Str("actor", actor).Msg("admin purge: DeleteAllByDID failed")
831831+ http.Error(w, "purge failed", http.StatusInternalServerError)
832832+ return
833833+ }
834834+ h.feedIndex.InvalidatePublicCachesForDID(didStr)
835835+836836+ log.Warn().Str("did", didStr).Str("actor", actor).Msg("admin purge: removed all witness data for DID")
837837+838838+ w.Header().Set("Content-Type", "application/json")
839839+ _ = json.NewEncoder(w).Encode(map[string]any{
840840+ "did": didStr,
841841+ "purged": true,
842842+ "purgedAt": time.Now().UTC(),
843843+ })
844844+}
+2
internal/routing/routing.go
···198198 middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminStats))))
199199 mux.Handle("GET /_mod/export", middleware.RequireAdmin(modSvc,
200200 http.HandlerFunc(h.HandleAdminExportDID)))
201201+ mux.Handle("POST /_mod/purge", cop.Handler(
202202+ middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleAdminPurgeDID))))
201203202204 // Static files (must come after specific routes)
203205 fs := http.FileServer(http.Dir("static"))