AACsearch
Architecture

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)
    end

What each step guarantees

  1. 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.
  2. 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 if exp is in the past. This is Hard Invariant #4.
  3. Parent key verification. The parent ss_search_* (or ss_scoped_* parent) is sha256-hashed and looked up in SearchApiKey.hash. Plaintext keys are never stored — Hard Invariant #3.
  4. Origin allow-list. When allowedOrigins is non-empty, the Origin header is strictly matched. Failures return 403 before Typesense is touched.
  5. Rate limit. Per-key sliding window; 429 is returned for excess.
  6. Tenant filter combine. The Typesense filter_by always AND-combines the customer-supplied filter, the scoped-token filter (if present), and the server-injected tenantId:<org> clause. The tenant filter is appended at the SQL/Typesense WHERE layer, never as a header — Hard Invariant #5.
  7. multi_search dispatch. A single round trip to Typesense fans out per-search entries. Each entry MUST carry the same tenant filter.
  8. Response sanitization. buildSearchResponse strips internal fields, applies the configured highlight tags, and emits the public response shape.
  9. Analytics. recordSearchUsageAsync enqueues a SearchUsageEvent row of type=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 filterBy constraint 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.

On this page