diff --git a/README.md b/README.md index f990d97..04ba2a6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ canonical endpoint (self-owned [lexicon](lexicons/tech.waow.typeahead.searchActo curl "https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=nate&limit=10" ``` +`q` is a prefix matched against handles and display names — or a full DID (`did:plc:…` / `did:web:…`), which performs an exact lookup and returns that actor alone. + also served at `/xrpc/app.bsky.actor.searchActorsTypeahead` as a drop-in alias for migrating off bluesky's appview — identical params and response, kept indefinitely. ## stack diff --git a/lexicons/tech.waow.typeahead.searchActors.json b/lexicons/tech.waow.typeahead.searchActors.json index 102d8a7..e2211af 100644 --- a/lexicons/tech.waow.typeahead.searchActors.json +++ b/lexicons/tech.waow.typeahead.searchActors.json @@ -11,7 +11,7 @@ "properties": { "q": { "type": "string", - "description": "Search prefix; matched case-insensitively against handles and display names (CJK display names are matched via bigrams)." + "description": "Search prefix; matched case-insensitively against handles and display names (CJK display names are matched via bigrams). A full DID (did:plc:… or did:web:…) instead performs an exact lookup and returns that actor alone." }, "limit": { "type": "integer", diff --git a/scripts/smoke.py b/scripts/smoke.py index f98b533..4437682 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -151,6 +151,35 @@ def test_prefix_match(base_url: str): check("prefix 'zzst' finds zzstoatzz.io", "zzstoatzz.io" in handles, f"got {handles[:5]}") +def test_did_search(base_url: str): + """q= resolves that exact actor at rank 1 via the hydrate path. + + Asserts the ENABLED behavior on purpose: a deploy with DID_SEARCH_DISABLED + accidentally set fails here instead of silently shipping the feature dark. + (validated live 2026-08-16: this DID resolves to zzstoatzz.io) + """ + print("\n--- DID search ---") + did = "did:plc:xbtmt2zjwlrfegqvch7fboei" # zzstoatzz.io + data, _ = fetch(f"{base_url}{XRPC_PATH}?q={did}&limit=10") + if not data or "_error" in data or "_http_error" in data: + check("fetch succeeded", False) + return + + actors = data.get("actors", []) + check("DID query returns exactly one actor", len(actors) == 1, f"got {len(actors)}") + if actors: + check("DID query rank 1 is that actor", actors[0].get("did") == did, + f"got {actors[0].get('did')}") + check("DID query hydrates the handle", actors[0].get("handle") == "zzstoatzz.io", + f"got {actors[0].get('handle')}") + + unknown = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" + data, _ = fetch(f"{base_url}{XRPC_PATH}?q={unknown}&limit=10") + check("unknown DID returns empty actors (200)", + data is not None and "_http_error" not in data and data.get("actors") == [], + f"got {data}") + + def test_ranking(base_url: str): """popularity ranking — a high-follower account must rank above low-follower namesakes that share its prefix. Guards the quality_score-clobber class of @@ -613,6 +642,7 @@ def main(): test_canonical_endpoint(args.url) test_known_handle(args.url) test_prefix_match(args.url) + test_did_search(args.url) test_ranking(args.url) test_cors(args.url) test_deprecated_param(args.url) diff --git a/services/src/index/index_search.zig b/services/src/index/index_search.zig index 19f6b95..c9882ea 100644 --- a/services/src/index/index_search.zig +++ b/services/src/index/index_search.zig @@ -37,16 +37,16 @@ pub fn run(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit: defer arena_state.deinit(); const arena = arena_state.allocator(); - const norm = try normalize.query(arena, term); - if (norm.len == 0) { - try writer.writeAll("{\"actors\":[]}"); - return; - } - const lease = try local.checkoutRead(); defer lease.release(); - const candidates = try execute(arena, lease, norm, null); + const candidates = if (isDid(term)) + try hydrate(arena, lease, &.{.{ .did = term, .score = 0 }}) + else blk: { + const norm = try normalize.query(arena, term); + if (norm.len == 0) break :blk &[_]searchmod.Candidate{}; + break :blk try execute(arena, lease, norm, null); + }; var jw: json.Stringify = .{ .writer = writer }; try jw.beginObject(); @@ -58,6 +58,17 @@ pub fn run(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit: try jw.endObject(); } +/// A query that IS a DID skips key derivation and merge entirely: the DID goes +/// straight to hydrate(), so overlay-wins, tombstones, hidden flags, and the +/// live-actors safety belt apply exactly as they do for prefix results. Checked +/// on the sanitized term BEFORE normalize (which would mangle the colons). +fn isDid(term: []const u8) bool { + if (!std.mem.startsWith(u8, term, "did:plc:") and !std.mem.startsWith(u8, term, "did:web:")) + return false; + if (term.len == "did:plc:".len) return false; + return std.mem.indexOfScalar(u8, term, ' ') == null; +} + /// What the serving pipeline did with a query. Filled only when `/debug/search` /// asks for it — the point is that the explanation comes from the SAME code /// path that answered `/search`, not a parallel scorer that can drift out of @@ -127,6 +138,24 @@ pub fn debug(local: *LocalDb, allocator: Allocator, raw_query: []const u8, limit const arena = arena_state.allocator(); const term = searchmod.sanitize(raw_query); + + if (isDid(term)) { + const lease = try local.checkoutRead(); + defer lease.release(); + const candidates = try hydrate(arena, lease, &.{.{ .did = term, .score = 0 }}); + const n = @min(limit, candidates.len); + const rows = try arena.alloc(Report.Row, n); + for (candidates[0..n], rows) |*c, *r| r.* = .{ + .did = c.did, + .handle = c.handle, + .display_name = c.display_name, + .score = c.quality_score, + }; + var jw: json.Stringify = .{ .writer = writer }; + try jw.write(Report{ .q = raw_query, .normalized = term, .mode = "did", .merged = n, .candidates = rows }); + return; + } + const norm = if (term.len == 0) "" else try normalize.query(arena, term); var jw: json.Stringify = .{ .writer = writer }; @@ -380,6 +409,16 @@ test "promoteExact re-injects an exact hit the score cap evicted" { try testing.expectEqualStrings("tok2", out[2].did); } +test "isDid: accepts plc/web DIDs, rejects handles and phrases" { + try testing.expect(isDid("did:plc:xbtmt2zjwlrfegqvch7fboei")); + try testing.expect(isDid("did:web:zzstoatzz.io")); + try testing.expect(!isDid("did:key:zQ3sh")); // unsupported method + try testing.expect(!isDid("zzstoatzz.io")); + try testing.expect(!isDid("did:plc:abc def")); // internal space → ordinary query + try testing.expect(!isDid("did:plc:")); // empty method-specific id + try testing.expect(!isDid("")); +} + test "inClause builds the right placeholder count" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); diff --git a/src/handlers/search.test.ts b/src/handlers/search.test.ts new file mode 100644 index 0000000..472e9ea --- /dev/null +++ b/src/handlers/search.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { isDidTerm } from "./search"; + +// mirrors the isDid test in services/src/index/index_search.zig — the worker +// gate and the search service must classify DID terms identically. +test("isDidTerm accepts plc/web DIDs", () => { + expect(isDidTerm("did:plc:xbtmt2zjwlrfegqvch7fboei")).toBe(true); + expect(isDidTerm("did:web:zzstoatzz.io")).toBe(true); +}); + +test("isDidTerm rejects handles, phrases, and unsupported methods", () => { + expect(isDidTerm("zzstoatzz.io")).toBe(false); + expect(isDidTerm("did:key:zQ3sh")).toBe(false); + expect(isDidTerm("did:plc:abc def")).toBe(false); + expect(isDidTerm("did:plc:")).toBe(false); + expect(isDidTerm("")).toBe(false); +}); diff --git a/src/handlers/search.ts b/src/handlers/search.ts index 7ac87b3..f80042b 100644 --- a/src/handlers/search.ts +++ b/src/handlers/search.ts @@ -5,6 +5,25 @@ import { json, sanitize, avatarUrl, avatarFallbackUrl } from "../utils"; import { recordMetric, recordCacheHit, recordTrafficSource } from "../metrics"; import { throttledBackfill } from "../backfill"; +// Must agree with isDid() in services/src/index/index_search.zig — the worker +// gates exposure, the search service resolves the lookup. +export function isDidTerm(term: string): boolean { + return /^did:(plc|web):\S+$/.test(term); +} + +function actorView(r: ActorRow) { + return { + did: r.did, + handle: r.handle, + ...(r.display_name ? { displayName: r.display_name } : {}), + ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url, r.pds) } : {}), + ...(r.avatar_url && avatarFallbackUrl(r.did, r.avatar_url, r.pds) ? { avatarFallback: avatarFallbackUrl(r.did, r.avatar_url, r.pds) } : {}), + ...(r.associated && r.associated !== '{}' ? { associated: JSON.parse(r.associated) } : {}), + labels: JSON.parse(r.labels || '[]'), + ...(r.created_at ? { createdAt: r.created_at } : {}), + }; +} + export async function handleSearch( request: Request, db: TursoDB, @@ -29,6 +48,14 @@ export async function handleSearch( return json({ actors: [] }); } + // DID-query search: the backend resolves did:plc:/did:web: terms via its + // hydrate path. The capability lives there; the OFF switch lives here, at + // the layer with no-redeploy config (see DID_SEARCH_DISABLED in types.ts). + const didTerm = isDidTerm(term); + if (didTerm && env.DID_SEARCH_DISABLED) { + return json({ actors: [] }); + } + // Emergency kill switch: bypass the whole pipeline and proxy to Bluesky. // Set/unset via `bunx wrangler secret put|delete EMERGENCY_PROXY_BSKY`. // No cache writes here — old cache entries expire (60s) and the moment the @@ -110,10 +137,6 @@ export async function handleSearch( } } - // fallback: 3-tier ranking via turso. Overfetch prefix + FTS so the - // interleaved merge below has real candidates to pick from. - const ftsQuery = `"${term}"*`; - const overfetch = Math.min(limit * 5, 50); // Bounded, like the backend hop above it. // // This path now runs whenever the replica answers empty, not just when it is @@ -122,6 +145,41 @@ export async function handleSearch( // not have yet. Answering "nothing found" quickly beats answering correctly // eventually: the caller is a typeahead, and the next keystroke is 200ms away. const FALLBACK_BUDGET_MS = 2500; + + // DID fallback: the prefix/FTS tiers below can't match a DID, so the Turso + // authority check is a single primary-key read. + if (didTerm) { + let row: ActorRow | null = null; + try { + row = await Promise.race([ + db.prepare( + `SELECT did, handle, display_name, avatar_url, labels, created_at, associated, pds + FROM actors WHERE did = ?1 AND hidden = 0 LIMIT 1` + ).bind(term).first(), + new Promise((_, rej) => + setTimeout(() => rej(new Error("turso_fallback_timeout")), FALLBACK_BUDGET_MS)), + ]); + } catch (e: any) { + console.log(JSON.stringify({ event: "search_fallback_timeout", term, error: e?.message })); + return json({ actors: [] }); + } + + ctx.waitUntil(Promise.all([ + recordMetric(db, Date.now() - t0), + recordTrafficSource(db, request), + ])); + + const response = json({ actors: row ? [actorView(row)] : [] }); + const cacheable = new Response(response.clone().body, response); + cacheable.headers.set("Cache-Control", "public, max-age=60"); + ctx.waitUntil(cache.put(cacheKey, cacheable)); + return response; + } + + // fallback: 3-tier ranking via turso. Overfetch prefix + FTS so the + // interleaved merge below has real candidates to pick from. + const ftsQuery = `"${term}"*`; + const overfetch = Math.min(limit * 5, 50); const batch = db.batch([ db.prepare( `SELECT did, handle, display_name, avatar_url, labels, created_at, associated, pds @@ -212,16 +270,7 @@ export async function handleSearch( takePrefix = !takePrefix; } - const actors = merged.map((r) => ({ - did: r.did, - handle: r.handle, - ...(r.display_name ? { displayName: r.display_name } : {}), - ...(r.avatar_url ? { avatar: avatarUrl(r.did, r.avatar_url, r.pds) } : {}), - ...(r.avatar_url && avatarFallbackUrl(r.did, r.avatar_url, r.pds) ? { avatarFallback: avatarFallbackUrl(r.did, r.avatar_url, r.pds) } : {}), - ...(r.associated && r.associated !== '{}' ? { associated: JSON.parse(r.associated) } : {}), - labels: JSON.parse(r.labels || '[]'), - ...(r.created_at ? { createdAt: r.created_at } : {}), - })); + const actors = merged.map(actorView); // --- backfill: remove this block once at parity with Bluesky --- const hasGaps = actors.length < limit || actors.some((a) => !a.avatar); diff --git a/src/pages/docs.ts b/src/pages/docs.ts index 452f683..b2e632f 100644 --- a/src/pages/docs.ts +++ b/src/pages/docs.ts @@ -105,7 +105,10 @@ export function docsPage(): string {
GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=...&limit=10

params are q and limit (1–100), returning - { "actors": [...] }. this is the endpoint to use — + { "actors": [...] }. q is a prefix matched against + handles and display names — or a full DID (did:plc:… / + did:web:…), which performs an exact lookup and returns that + actor alone. this is the endpoint to use — app.bsky.* names belong to bluesky's lexicons, and this service isn't bluesky. there's also a machine-readable summary at /llms.txt.

diff --git a/src/pages/home.ts b/src/pages/home.ts index f448e1c..34c2a36 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -100,7 +100,7 @@ export function indexPage(): string {
- +
diff --git a/src/pages/llms.ts b/src/pages/llms.ts index 656cdfc..50b6852 100644 --- a/src/pages/llms.ts +++ b/src/pages/llms.ts @@ -9,7 +9,8 @@ experimental: may break or disappear — don't depend on it for anything critica GET https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors?q=&limit=10 -- q: required. search prefix, matched against handles and display names. +- q: required. search prefix, matched against handles and display names. a full DID + (did:plc:… or did:web:…) instead performs an exact lookup and returns that actor alone. - limit: 1-100, default 10. - response: { "actors": [ { did, handle, displayName?, avatar?, associated?, labels?, createdAt? } ] } (the shape of app.bsky.actor.defs#profileViewBasic minus the authenticated "viewer" field) diff --git a/src/types.ts b/src/types.ts index 38eac2e..9242f54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,13 @@ export interface Env { /// Turn off with: /// bunx wrangler secret delete EMERGENCY_PROXY_BSKY EMERGENCY_PROXY_BSKY?: string; + /// Kill switch for DID-query search (q=did:plc:… / did:web:…). When set + /// (any non-empty value), DID-shaped queries return {actors:[]} without + /// reaching the backend — same no-redeploy pattern as EMERGENCY_PROXY_BSKY: + /// bunx wrangler secret put DID_SEARCH_DISABLED + /// bunx wrangler secret delete DID_SEARCH_DISABLED + /// The 60s edge cache means a flip takes up to a minute to fully apply. + DID_SEARCH_DISABLED?: string; /// Discord-compatible webhook. When set, the hourly cron posts here on a /// freshness STATE CHANGE (ok -> stale, stale -> ok). Set via: /// bunx wrangler secret put ALERT_WEBHOOK_URL