Read path
How a search query flows through AACsearch — from the customer SDK to /search/public/multi, through public-auth and tenant-filter combine, into Typesense multi_search, and back as a sanitized response.
The read path is stateless and hot: no row is written to PostgreSQL on the synchronous search request (usage events are recorded fire-and-forget). The whole chain — auth, scoped filter combine, tenant filter combine, multi_search dispatch — is designed to add the smallest possible overhead on top of the underlying Typesense call.
Flow
Description
The diagram traces a public search request from the customer SDK through token verification (HMAC-checked scoped token or hash-looked-up parent key), origin and rate-limit gates, then through combineTenantFilter, which AND-combines the user filter, the scoped-token filter, and the server-injected tenantId clause before dispatching multi_search to Typesense. The sanitized response is returned synchronously while a SearchUsageEvent row is enqueued asynchronously for analytics.
sequenceDiagram
autonumber
participant SDK as Customer SDK / Widget
participant API as packages/api/modules/search/public-handler.ts
participant Gate as gatePublicSearchRequest
participant ST as verifyScopedSearchToken (HMAC + exp)
participant Key as verifySearchApiKey (hash-only)
participant RL as SearchRateLimitBucket
participant Filt as combineTenantFilter
participant TS as Typesense multi_search
participant Ev as SearchUsageEvent (async)
SDK->>API: POST /api/search/public/multi
API->>Gate: Bearer header (ss_search_* OR ss_scoped_*)
alt ss_scoped_* prefix
Gate->>ST: decode + HMAC verify + exp check
ST-->>Gate: { parentRawKey, filterBy (scoped), keyId }
end
Gate->>Key: verifySearchApiKey(parentRawKey, scope=search)
Key-->>Gate: VerifiedSearchKey { organizationId, indexSlug }
Gate->>Gate: origin allow-list check
Gate->>RL: increment + compare rateLimitPerMinute
Gate-->>API: { verified, scopedFilter }
API->>Filt: combine(userFilter && tenantFilter && scopedFilter)
Filt-->>API: final filter_by expression
API->>TS: multi_search(searches[], common_params)
TS-->>API: hits + facets per search entry
API->>API: buildSearchResponse (PII strip, highlight tags)
API-->>SDK: 200 sanitized JSON
par fire-and-forget
API->>Ev: recordSearchUsageAsync(search_query)
endWhat each step guarantees
- Token shape. Only three prefixes are accepted:
ss_search_*,ss_scoped_*, and (for connector writes)ss_connector_*. The shape check happens before any DB read. - Scoped token verification.
ss_scoped_*tokens carry an HMAC-SHA256 signature over a base64url-encoded payload{ keyId, parentRawKey, filterBy, exp }. The server constant-time compares the signature, decodes the parent key from inside, and refuses ifexpis in the past. This is Hard Invariant #4. - Parent key verification. The parent
ss_search_*(orss_scoped_*parent) is sha256-hashed and looked up inSearchApiKey.hash. Plaintext keys are never stored — Hard Invariant #3. - Origin allow-list. When
allowedOriginsis non-empty, theOriginheader is strictly matched. Failures return 403 before Typesense is touched. - Rate limit. Per-key sliding window; 429 is returned for excess.
- Tenant filter combine. The Typesense
filter_byalways AND-combines the customer-supplied filter, the scoped-token filter (if present), and the server-injectedtenantId:<org>clause. The tenant filter is appended at the SQL/TypesenseWHERElayer, never as a header — Hard Invariant #5. - multi_search dispatch. A single round trip to Typesense fans out per-search entries. Each entry MUST carry the same tenant filter.
- Response sanitization.
buildSearchResponsestrips internal fields, applies the configured highlight tags, and emits the public response shape. - Analytics.
recordSearchUsageAsyncenqueues aSearchUsageEventrow oftype=search_query. The promise is intentionally not awaited.
Why scoped tokens
A scoped token is the only safe way to embed an AACsearch-driven search box in a customer-facing page. It carries:
- a stable HMAC the customer cannot forge (server-only
BETTER_AUTH_SECRET), - a
filterByconstraint that the API will AND-combine with every request (e.g.user_id:42 && shop_id:abc), - a short
exp(typically 15 minutes), so a leaked token has a small blast radius.
Related
- Key types & security model — full comparison of the four key categories.
- Write path — the mirror flow for ingest.
Write path
How a document write enters AACsearch — from POST /v1/indexes/.../documents through SearchIngestBuffer / SearchSyncOutbox and the worker, into the Typesense alias.
Key types & security model
The four AACsearch key categories — search, connector, scoped, and admin — their ss_* prefixes, hash-only storage, scoped-token HMAC + TTL + filter, and how each one is verified at request time.