mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
## 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>
85 lines
4.1 KiB
Markdown
85 lines
4.1 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{ "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:
|
|
|
|
```sql
|
|
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.
|