Expand description
GET /api/search — the single full-text query surface.
§Authorization
The endpoint is reachable unauthenticated — it sits under optional_auth
in routes/public.rs, and a tokenless caller is treated as the "guest"
subject, which the derivation below turns into SubjectAccessLevel::Public,
the level GET /api/files gives the same caller. Three layers, in order:
-
Scoped tokens. A share-link holder carries
file:{id}:{R|C|W}. Only that variant is accepted — an unparseable orapkg:publishscope is a hard 403, never a widening. The token confines the query to its own document tree and to file/document rows: a link recipient has no business searching the tenant’s posts or profiles.A scope narrows the subtree; the visibility derivation below still runs for every caller and the scope applies on top of it. A share link to a folder is not a grant over that folder’s Direct and Connected children, and
GET /api/filesdoes not treat it as one either. The one exception isSearchOptions::scope_grant_file_id: the shared file’s own row and the deep'D'parts of its tree bypass the level filter, because the share is permission to read that document — without it a link to a private note would search to zero hits inside a note its holder can open. Child'F'rows in the tree keep the level filter.A scoped token is never the owner, whatever its subject says: share-link tokens are minted with
sub: Noneandiss= the tenant, and validation resolvesid_tagassub.unwrap_or(iss). See [subject_level]. -
SQL prefilter. Per object type, the same rule the corresponding list endpoint applies, so pagination counts only rows the caller could see:
'P'profiles are not filtered — tenant-scoped, and any authenticated caller in the tenant may find them, exactly asGET /api/profilesallows. That does not extend to an anonymous caller, so an unauthenticated request has'P'dropped from itsobj_tpfilter outright (see [guest_obj_tp]); otherwise anyone could enumerate the tenant’s whole contact graph, cached remote profiles included.'F'files and'D'deep parts are filtered byvisible_levels, derived from the caller’s relationship to the tenant precisely asGET /api/filesderives it — the tenant owns both.'A'actions use the canonical predicate shared withGET /api/actions, keyed on the issuer, not the tenant: following the tenant does not make the caller a follower of every issuer whose posts it federated in.
An owner — caller
id_tag== tenantid_tagand no scope — getsvisible_levels = None, and then no predicate is emitted at all. -
Redundant post-check. The SQL prefilter above is the authorization.
file_access::check_scope_allows_fileis exactlyfile_id == scope || root_id == scope, the same predicate the adapter already pushed down forscope_file_id, so under a correct prefilter it never drops a row. Kept as a cross-check against future drift in either half, and loud: a non-zero drop count logs atwarn!.
§Pagination
Results are relevance-ordered, so this endpoint uses limit/offset rather
than the keyset cursor the rest of the API uses — a cursor over a bm25()
ordering has nothing stable to anchor on. offset is capped.
pagination.total is the only has-more signal the response carries: derived
from the page alone it would equal offset + len and every page would look
like the last one. It normally comes from a second adapter call,
cloudillo_types::meta_adapter::MetaAdapter::count_search, over the same SQL
filters as the page itself.
That second call is skipped when the page answers the question by itself:
a first page (offset == 0) shorter than limit is the whole match set, so
its length is the total. This is the common case on a route reachable with
no token at all, where ?q=a — a "a"* prefix matching most of the corpus —
would otherwise cost two full ranked FTS scans, each re-running the per-row
correlated EXISTS over actions. The decision reads the raw adapter row
count, before the post-check, so it stays consistent with what the SQL
matched. Not tokio::join!ed with the page: that would undo the skip.
When it does run, the count saturates at
SEARCH_MAX_OFFSET + SEARCH_MAX_LIMIT. offset is clamped to
SEARCH_MAX_OFFSET, so no page a caller can reach lies past it and the
has-more signal stays exact where a caller can act on it. Uncapped, such a
request would walk the tenant’s whole match set.
Structs§
- Search
Hit - One result row on the wire.
- Search
Query - Query parameters for
GET /api/search.