Files
buzz/docs/profile-search.md
Kalvin C 3d26ed7dfd feat(relay): match verified names in profile search (#9019)
## 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>
2026-11-03 21:25:15 -05:00

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 of limit results, 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:

  1. a name equal to the query;
  2. a name that starts with the query;
  3. a name with a later word that starts with the query;
  4. 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.