feat(#571): search-only recipient picker source #576

Merged
Cordy merged 7 commits from fix-571b into main 2026-09-17 12:01:18 +00:00
Owner

GET /api/v1/recipients?q= — the source behind the share panel's recipient picker.

Why not the endpoint the issue named. #571 said to reuse GET /api/v1/admin/groups/candidates. It is registered as requireAdmin, so the picker would 403 for every non-admin — which is almost everyone who shares a file. It also answers "list everything" with no query at all: the right shape for an admin screen, the wrong shape for a control every session touches. Exposing it would have added a user-enumeration surface on purpose.

So this one cannot be asked for everything. A query is mandatory and must be at least 2 characters; a shorter one is a 400 that says so, rather than an empty list. That is deliberate — an empty list would let a client treat "" as "list everyone" and get a plausible-looking answer.

Prefix-only, but on token boundaries. You approved "prefix matches only". A strict whole-name prefix would make anyone with a firstname.surname account unfindable by surname, so a prefix of any token (., -, _, space, @) counts. Mid-token matching stays out: anika does not match ni, so nobody walks the directory two characters at a time. TestRecipientMatchesIsPrefixOnly and TestRecipientMatchesFindsTokenPrefixes pin both halves.

Ranking, then the cap. Exact match, then whole-name prefix, then token prefix, then alphabetical. The cap of 20 is applied after ranking, so truncation drops the worst matches rather than whichever sorted first — and truncated comes back in the response so the UI can ask for more characters instead of quietly understating how many people matched.

Smaller decisions, each with a test: the caller is excluded (sharing with yourself is not a thing anyone means to do); a name offered by two sources is one recipient, but a user and a group of the same name are two, because they are two different things to share with; an unreachable directory degrades the picker to what it can see rather than failing it.

Registration sits before the store guard in registerGroups, so the picker still works on a local-only instance where the accounts alone are the picker.

Two notes on how this went, since both are recorded in the workflow comments. The one-shot detects the group lister from *GroupStore's own method set rather than from call sites — the first attempt scanned groups.go for h.Groups.Store.X( and found only mutators, because the listing happens through another path. And the first green failed on my test, not the code: interpolating a query containing raw spaces into a URL makes httptest.NewRequest panic on the request line. All nine pure-function tests had already passed; the fix was url.QueryEscape.

`GET /api/v1/recipients?q=` — the source behind the share panel's recipient picker. **Why not the endpoint the issue named.** #571 said to reuse `GET /api/v1/admin/groups/candidates`. It is registered as `requireAdmin`, so the picker would 403 for every non-admin — which is almost everyone who shares a file. It also answers "list everything" with no query at all: the right shape for an admin screen, the wrong shape for a control every session touches. Exposing it would have added a user-enumeration surface on purpose. **So this one cannot be asked for everything.** A query is mandatory and must be at least 2 characters; a shorter one is a 400 that says so, rather than an empty list. That is deliberate — an empty list would let a client treat `""` as "list everyone" and get a plausible-looking answer. **Prefix-only, but on token boundaries.** You approved "prefix matches only". A strict whole-name prefix would make anyone with a `firstname.surname` account unfindable by surname, so a prefix of any token (`.`, `-`, `_`, space, `@`) counts. Mid-token matching stays out: `anika` does not match `ni`, so nobody walks the directory two characters at a time. `TestRecipientMatchesIsPrefixOnly` and `TestRecipientMatchesFindsTokenPrefixes` pin both halves. **Ranking, then the cap.** Exact match, then whole-name prefix, then token prefix, then alphabetical. The cap of 20 is applied *after* ranking, so truncation drops the worst matches rather than whichever sorted first — and `truncated` comes back in the response so the UI can ask for more characters instead of quietly understating how many people matched. Smaller decisions, each with a test: the caller is excluded (sharing with yourself is not a thing anyone means to do); a name offered by two sources is one recipient, but a user and a group of the same name are two, because they are two different things to share with; an unreachable directory degrades the picker to what it can see rather than failing it. **Registration** sits before the store guard in `registerGroups`, so the picker still works on a local-only instance where the accounts alone *are* the picker. Two notes on how this went, since both are recorded in the workflow comments. The one-shot detects the group lister from `*GroupStore`'s own method set rather than from call sites — the first attempt scanned `groups.go` for `h.Groups.Store.X(` and found only mutators, because the listing happens through another path. And the first green failed on **my test**, not the code: interpolating a query containing raw spaces into a URL makes `httptest.NewRequest` panic on the request line. All nine pure-function tests had already passed; the fix was `url.QueryEscape`.
Cordy merged commit 27a6a855e3 into main 2026-09-17 12:01:18 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#576
No description provided.