## Summary `POST /profiles/search` only matches kind:0 `name` and `display_name`. In communities that publish NIP-FI verified profiles, a person's surname or directory username often appears only in their relay-signed kind:30382, so typeahead cannot find them by it. - Where the community advertises verified profiles, the search also matches the `display_name`, `username` and `full_name` tags of each current kind:30382 head signed by the relay key. - Verified names need a trailing prefix of at least three characters, as in NIP-50, so a short prefix doesn't make the relay rank every verified head. kind:0 keeps one-character typeahead. Lookups from heads to `events` also match `created_at`, so Postgres prunes to one partition. - Results are paged by person. Each person returns their current kind:0, when they have one, then their current kind:30382, when they have one, whichever matched. A person is ranked by their best match across both sources, using the existing tiers. - NIP-11 `federated_identity.public_profile` gains `searchable: true`, so clients can rely on the response instead of a separate kind:30382 read. - Migration `0081` adds a GIN expression index over the head's name tags on `nip_fi_profile_heads` (built-in `jsonb_path_query_first` and `to_tsvector`, no new functions). The table holds one row per verified person. Like `0078`, the migration builds the index only while the table is empty; populated databases prebuild it with `CREATE INDEX CONCURRENTLY` (`docs/profile-search.md`), and the migration then checks it is valid and has the expected definition. Compatibility: the request schema is unchanged. Existing clients that keep only kind:0 events work as before and also find people who match only by verified name. Trust stays pinned to the relay `self` key; the event shape follows NIP-85 user assertions, but discovery is relay-advertised rather than kind:10040. ## Testing - `buzz-search` Postgres integration: new `profile_names_match_verified_names` covers forged-key heads, replaced heads, other communities, people without kind:0, per-person grouping, paging, and words split across sources; the existing profile-name tests pass unchanged. The test fails when the author filter is inverted. - `buzz-relay` end to end in enforce mode: `profile_search_matches_verified_names_where_advertised` publishes through the real writer and finds the person by surname only where verified profiles are advertised. - Desired-state and migration index definitions match. - `profile_names_use_verified_name_index` asserts the search scans the new index. Migration `0081` checked against an empty table, a rerun, a populated table without the index (raises), the documented concurrent prebuild, and an index with a different definition (raises). - Latency, 50k people each with a kind:0 and a verified head, release build, local Postgres, best of 5, 30 people per page: 11–22 ms with verified names versus 1–8 ms kind:0 only. `EXPLAIN` shows a bitmap scan on the new index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
4.1 KiB
Profile name search
POST /profiles/search finds community members by profile name. It matches
only the name and display_name string fields of each current global kind:0
profile and, where the community advertises NIP-FI verified profiles
(federated_identity.public_profile.searchable in NIP-11), the
display_name, username and full_name tags of each current kind:30382
verified profile signed by the relay key. It does not search picture
(including data URLs), about, nip05, banner, or any other profile field. NIP-50 search over kind:0 matches the
whole profile JSON, so it can return profiles whose avatar data or bio happens
to contain the query text.
Apart from the fields it reads, the endpoint matches and pages like a NIP-50
kind:0 search with search_mode: "prefix". A client can switch from one to the
other without a visible change in which people a query finds.
The endpoint requires NIP-98 authentication over the request body and relay
membership, like other relay-owned member endpoints. A relay without the
endpoint answers 404; clients fall back to NIP-50 search for that request.
Request:
{ "query": "wes", "limit": 20, "page": 1 }
query: 1 through 100 characters after trimming. Required.limit: results per page, 1 through 100. Defaults to 20.page: 1-based page oflimitresults, 1 through 1000. Defaults to 1.- Unknown fields are rejected with
400.
Response: a JSON array of signed events for one page of people, best match
first. Each person contributes their current kind:0, when they have one, then
their current relay-signed kind:30382, when verified profiles are advertised
and they have one, whichever of the two matched. A page with fewer than
limit people is the last page. Clients that read only kind:0 keep working;
they also find people who match only by verified name.
Matching is the NIP-50 prefix rule, case-insensitive. The query is split on
whitespace. Every word except the last must equal a word of name or
display_name; the last word may be a word prefix. Words may appear in any
order and may be split across the two fields, so "johnson wes" finds
"Wes Johnson". A verified profile matches the same way over its three name
tags, except that its last word must be at least three characters, as in
NIP-50. The two sources match separately: a query whose words are split
between a person's kind:0 and their verified profile does not match.
Accents are not folded.
People are ordered by their best match across both sources:
- a name equal to the query;
- a name that starts with the query;
- a name with a later word that starts with the query;
- any other match.
For these tiers the query and the names are lower-cased, with whitespace
runs collapsed to one space and trimmed, by the same database expression.
Ties go by relevance of the query to the names alone (not the whole
profile, as in NIP-50), then newest first, then pubkey, so pages do not
overlap. Profile content that is not a JSON object, or a name field that
is not a string, never matches. Content that PostgreSQL cannot read as jsonb
(for example a \u0000 escape or very deep nesting) has no names, so it
never matches and never makes the search fail
(schema/functions/profile_name_fields.sql).
Deployment
Verified-name matching reads idx_nip_fi_profile_heads_names. Migration
0081 builds it only while nip_fi_profile_heads is empty; on a populated
database it fails until the index exists. Build it first, outside a
transaction:
CREATE INDEX CONCURRENTLY idx_nip_fi_profile_heads_names
ON public.nip_fi_profile_heads USING GIN (
to_tsvector('simple',
COALESCE(jsonb_path_query_first(event, 'strict $.tags[*] ? (@[0] == "display_name")[1]', '{}', true) #>> '{}', '')
|| ' ' || COALESCE(jsonb_path_query_first(event, 'strict $.tags[*] ? (@[0] == "username")[1]', '{}', true) #>> '{}', '')
|| ' ' || COALESCE(jsonb_path_query_first(event, 'strict $.tags[*] ? (@[0] == "full_name")[1]', '{}', true) #>> '{}', ''))
);
If the build fails, drop the invalid index and build it again. The migration then checks that the index is valid and has this definition.