diff --git a/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs b/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs
new file mode 100644
index 00000000..e99441e0
--- /dev/null
+++ b/src/Yavsc.Blogs/Controllers/UserSearchApiController.cs
@@ -0,0 +1,111 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Models;
+
+namespace Yavsc.Blogs.Controllers
+{
+ ///
+ /// Central user search endpoint used by client address books
+ /// (PostIt.Desktop, future PostIt.Browser CLI, etc.).
+ ///
+ /// Live in Yavsc.Blogs rather than Yavsc.Api
+ /// because Yavsc.Api is not yet enabled in production; future
+ /// migration is mechanical (the namespace and route prefix are
+ /// the only ties to the host project).
+ ///
+ /// Authorisation: any authenticated caller can search.
+ /// Results include Email on a best-effort basis —
+ /// the field is included because the address-book use case
+ /// (composing a circle membership, sending an invite) needs
+ /// it. The data set is the entire user table of the
+ /// instance, which on Yavsc's single-tenant deployments is
+ /// a closed community where users already know each other.
+ /// Multi-tenant deployments should gate this controller
+ /// behind a tenant-scoped authorisation policy before
+ /// exposing it.
+ ///
+ [Produces("application/json")]
+ [Route("api/user-search")]
+ [Authorize]
+ public class UserSearchApiController : Controller
+ {
+ private readonly ApplicationDbContext _context;
+
+ public UserSearchApiController(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Search users by display name and/or email.
+ ///
+ /// Substring filter on
+ /// or
+ /// (case-insensitive,
+ /// contains). Optional.
+ /// Exact filter on
+ /// (case-insensitive
+ /// equality). Optional.
+ /// Maximum number of results, capped at
+ /// 100. Default 25.
+ // GET: api/user-search?q=foo&e=bar@example.com&take=25
+ [HttpGet]
+ public async Task> SearchAsync(
+ [FromQuery] string? q = null,
+ [FromQuery] string? e = null,
+ [FromQuery] int take = 25)
+ {
+ take = Math.Clamp(take, 1, 100);
+
+ IQueryable query = _context.Users;
+
+ if (!string.IsNullOrWhiteSpace(e))
+ {
+ // Email is treated as an exact match — most address
+ // book callers already know the email they're
+ // searching for and we don't want to surface a
+ // long tail of partial matches.
+ var normalised = e.Trim();
+ query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower());
+ }
+
+ if (!string.IsNullOrWhiteSpace(q))
+ {
+ var needle = q.Trim();
+ query = query.Where(u =>
+ (u.FullName != null && u.FullName.ToLower().Contains(needle.ToLower())) ||
+ (u.UserName != null && u.UserName.ToLower().Contains(needle.ToLower())));
+ }
+
+ var results = await query
+ .OrderBy(u => u.FullName ?? u.UserName)
+ .Take(take)
+ .Select(u => new UserSearchResultDto
+ {
+ Id = u.Id,
+ UserName = u.UserName ?? string.Empty,
+ FullName = u.FullName,
+ Avatar = u.Avatar,
+ Email = u.Email,
+ })
+ .ToListAsync();
+
+ return results;
+ }
+ }
+
+ ///
+ /// Search-result shape. Flat DTO with no navigation
+ /// properties so the JSON stays small even if the user
+ /// table grows.
+ ///
+ public sealed class UserSearchResultDto
+ {
+ public string Id { get; set; } = string.Empty;
+ public string UserName { get; set; } = string.Empty;
+ public string? FullName { get; set; }
+ public string? Avatar { get; set; }
+ public string? Email { get; set; }
+ }
+}
\ No newline at end of file