Skip to content

Commit e3ecb57

Browse files
vladjercaseferturan
authored andcommitted
fix(search): cap per-search compute via embedded scoped-key params
Scoped search keys are handed to the browser, so any client-side clamp on the query is bypassable by hitting Typesense directly with the token. Only params baked into the scoped key are enforced by Typesense (the client cannot override them). - Bake search_cutoff_ms + per_page (alongside the existing limit_hits) into every scoped key. search_cutoff_ms is a hard per-search time wall: normal short queries finish well under it, while pathological large queries return early instead of allocating a huge expansion state. - Shorten the scoped-key TTL from 6h to 15m to shrink the replay window on a leaked token. - Extract the mint into mintSearchKeys() so the root handle hook and a new /api/search-keys endpoint share one definition. - Re-mint on demand: the search context now holds the keys in a BehaviorSubject and refreshes them via /api/search-keys before the TTL lapses, so a long-lived tab keeps searching with a live key. num_typos / max_candidates are intentionally left at preset defaults: the cutoff already closes the abuse vector without trimming typo tolerance.
1 parent ed5ce05 commit e3ecb57

10 files changed

Lines changed: 203 additions & 80 deletions

File tree

projects/client/src/lib/features/search/_internal/SearchContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ export type SearchContext = {
66
isSearching: BehaviorSubject<boolean>;
77
pathName: string;
88
query: BehaviorSubject<string>;
9-
config: TypesenseConfig;
9+
config: BehaviorSubject<TypesenseConfig>;
1010
};

projects/client/src/lib/features/search/_internal/createSearchContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function createSearchContext(
2424
isSearching: new BehaviorSubject(false),
2525
pathName: UrlBuilder.search(),
2626
query: new BehaviorSubject(''),
27-
config,
27+
config: new BehaviorSubject(config),
2828
},
2929
);
3030

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { BehaviorSubject } from 'rxjs';
2+
import { fetchSearchKeys } from './fetchSearchKeys.ts';
3+
import { SEARCH_KEY_LIFETIME } from './searchKeyLifetime.ts';
4+
5+
// Refresh coordination shared across every useSearch() instance. useSearch is
6+
// mounted by multiple components and its pipeline can run more than once per
7+
// search, so keeping this state in the hook closure minted a key per run.
8+
// Hoisting it here means a stale key triggers a single re-mint, reused by all
9+
// callers, and the staleness clock is shared.
10+
let activeConfig: BehaviorSubject<TypesenseConfig> | null = null;
11+
let mintedAt = Date.now();
12+
let nextAttemptAt = 0;
13+
let refreshPromise: Promise<TypesenseConfig> | null = null;
14+
15+
export function ensureFreshSearchKeys(
16+
config: BehaviorSubject<TypesenseConfig>,
17+
): Promise<TypesenseConfig> {
18+
// A new provider means a freshly minted SSR key: reset the clock to it.
19+
if (config !== activeConfig) {
20+
activeConfig = config;
21+
mintedAt = Date.now();
22+
nextAttemptAt = 0;
23+
refreshPromise = null;
24+
}
25+
26+
const now = Date.now();
27+
const isStale = now - mintedAt >= SEARCH_KEY_LIFETIME.refreshInterval;
28+
if (!isStale || now < nextAttemptAt) {
29+
return Promise.resolve(config.value);
30+
}
31+
32+
// Reuse the in-flight refresh so concurrent searches share one request.
33+
if (!refreshPromise) {
34+
refreshPromise = fetchSearchKeys()
35+
.then((fresh) => {
36+
config.next(fresh);
37+
mintedAt = Date.now();
38+
return fresh;
39+
})
40+
.catch(() => {
41+
// Back off before retrying so a failing endpoint is not hit on every
42+
// subsequent search. Keep serving the current key meanwhile.
43+
nextAttemptAt = Date.now() + SEARCH_KEY_LIFETIME.retryCooldown;
44+
return config.value;
45+
})
46+
.finally(() => {
47+
refreshPromise = null;
48+
});
49+
}
50+
51+
return refreshPromise;
52+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { time } from '$lib/utils/timing/time.ts';
2+
import { z } from 'zod';
3+
4+
// Bound the request so a hung endpoint cannot leave a search pending forever
5+
// (the refresh sits in the search pipeline and gates the spinner).
6+
const REQUEST_TIMEOUT = time.seconds(5);
7+
8+
const TypesenseConfigSchema = z.object({
9+
keys: z.object({
10+
media: z.object({
11+
default: z.string(),
12+
exact: z.string(),
13+
}),
14+
people: z.string(),
15+
}),
16+
server: z.string(),
17+
});
18+
19+
export async function fetchSearchKeys(): Promise<TypesenseConfig> {
20+
const response = await fetch('/api/search-keys', {
21+
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
22+
});
23+
if (!response.ok) {
24+
throw new Error(`Failed to fetch search keys: ${response.status}`);
25+
}
26+
return TypesenseConfigSchema.parse(await response.json());
27+
}
Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,8 @@
1-
import { env } from '$env/dynamic/private';
21
import type { Handle } from '@sveltejs/kit';
3-
import { createSearcher } from '../../requests/search/createSearcher.ts';
4-
import { DEFAULT_SEARCH_LIMIT } from '../../utils/constants.ts';
5-
import { time } from '../../utils/timing/time.ts';
2+
import { mintSearchKeysFromEnv } from './mintSearchKeysFromEnv.ts';
63

74
export const handle: Handle = ({ event, resolve }) => {
8-
const typesenseKey = env.TYPESENSE_CLIENT_KEY ?? '';
9-
const typesenseServer = env.TYPESENSE_SERVER ?? '';
10-
11-
const typesense = createSearcher({
12-
key: typesenseKey ?? '',
13-
server: typesenseServer,
14-
});
15-
16-
const expires_at = Math.floor(
17-
(Date.now() + time.hours(6)) / time.seconds(1),
18-
);
19-
20-
const mediaSearchKey = typesense
21-
.keys()
22-
.generateScopedSearchKey(typesenseKey, {
23-
preset: 'search:media',
24-
limit_hits: DEFAULT_SEARCH_LIMIT,
25-
expires_at,
26-
});
27-
28-
const mediaSearchExactKey = typesense
29-
.keys()
30-
.generateScopedSearchKey(typesenseKey, {
31-
preset: 'search:media:exact',
32-
limit_hits: DEFAULT_SEARCH_LIMIT,
33-
expires_at,
34-
});
35-
36-
const peopleSearchKey = typesense
37-
.keys()
38-
.generateScopedSearchKey(typesenseKey, {
39-
preset: 'search:people',
40-
limit_hits: DEFAULT_SEARCH_LIMIT,
41-
expires_at,
42-
});
43-
44-
event.locals.typesense = {
45-
keys: {
46-
media: {
47-
default: mediaSearchKey,
48-
exact: mediaSearchExactKey,
49-
},
50-
people: peopleSearchKey,
51-
},
52-
server: env.TYPESENSE_SERVER ?? '',
53-
};
5+
event.locals.typesense = mintSearchKeysFromEnv();
546

557
return resolve(event);
568
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createSearcher } from '$lib/requests/search/createSearcher.ts';
2+
import { DEFAULT_SEARCH_LIMIT } from '$lib/utils/constants.ts';
3+
import { time } from '$lib/utils/timing/time.ts';
4+
import { SEARCH_KEY_LIFETIME } from './searchKeyLifetime.ts';
5+
6+
// Hard caps baked into the scoped key. Typesense applies embedded params
7+
// itself and the client cannot override them, so these enforce a per-search
8+
// compute ceiling even if the transient token is replayed directly.
9+
const SEARCH_CUTOFF_MS = 250; // tune toward 100-150 once p99 search latency is confirmed
10+
11+
type MintSearchKeysParams = {
12+
key: string;
13+
server: string;
14+
};
15+
16+
export function mintSearchKeys(
17+
{ key, server }: MintSearchKeysParams,
18+
): TypesenseConfig {
19+
const typesense = createSearcher({ key, server });
20+
const expires_at = Math.floor(
21+
(Date.now() + SEARCH_KEY_LIFETIME.ttl) / time.seconds(1),
22+
);
23+
24+
const scope = (preset: string) =>
25+
typesense
26+
.keys()
27+
.generateScopedSearchKey(key, {
28+
preset,
29+
limit_hits: DEFAULT_SEARCH_LIMIT,
30+
per_page: DEFAULT_SEARCH_LIMIT,
31+
search_cutoff_ms: SEARCH_CUTOFF_MS,
32+
expires_at,
33+
});
34+
35+
return {
36+
keys: {
37+
media: {
38+
default: scope('search:media'),
39+
exact: scope('search:media:exact'),
40+
},
41+
people: scope('search:people'),
42+
},
43+
server,
44+
};
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { env } from '$env/dynamic/private';
2+
import { mintSearchKeys } from './mintSearchKeys.ts';
3+
4+
// Boundary wrapper: reads the server-only secrets and delegates to the pure
5+
// mint. Keeps the secret names in one place across the handle hook and the
6+
// re-mint endpoint.
7+
export function mintSearchKeysFromEnv(): TypesenseConfig {
8+
return mintSearchKeys({
9+
key: env.TYPESENSE_CLIENT_KEY ?? '',
10+
server: env.TYPESENSE_SERVER ?? '',
11+
});
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { time } from '$lib/utils/timing/time.ts';
2+
3+
// Scoped-key lifecycle, shared by the server-side mint and the client refresh.
4+
// The client must re-mint before the server token expires, so refreshInterval
5+
// stays safely below ttl. retryCooldown throttles re-minting after a failed
6+
// refresh so a broken endpoint is not hammered on every keystroke.
7+
export const SEARCH_KEY_LIFETIME = {
8+
ttl: time.minutes(15),
9+
refreshInterval: time.minutes(12),
10+
retryCooldown: time.minutes(1),
11+
} as const;

projects/client/src/lib/features/search/useSearch.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import { browser } from '$app/environment';
22
import { useQueryClient } from '$lib/features/query/_internal/queryClientContext.ts';
33
import type { CreateQueryOptions } from '$lib/features/query/types.ts';
44
import { multicast } from '$lib/utils/store/multicast.ts';
5-
import { BehaviorSubject, combineLatest, of } from 'rxjs';
6-
import { debounceTime, map, shareReplay, switchMap, tap } from 'rxjs/operators';
5+
import { BehaviorSubject, combineLatest, from, of } from 'rxjs';
6+
import {
7+
catchError,
8+
debounceTime,
9+
map,
10+
shareReplay,
11+
switchMap,
12+
tap,
13+
} from 'rxjs/operators';
714
import type { SearchMode } from '../../requests/queries/search/models/SearchMode.ts';
815
import {
916
searchListsQuery,
@@ -27,6 +34,7 @@ import { createBulkMediaIntl } from '../intl-overlay/createBulkMediaIntl.ts';
2734
import { getSearchContext } from './_internal/getSearchContext.ts';
2835
import { mapToSearchCover } from './_internal/mapToSearchCover.ts';
2936
import { postRecentSearch } from './_internal/postRecentSearch.ts';
37+
import { ensureFreshSearchKeys } from './ensureFreshSearchKeys.ts';
3038
import type { SearchResponse } from './models/SearchResponse.ts';
3139

3240
function modeToQuery(
@@ -92,38 +100,46 @@ export function useSearch() {
92100
switchMap(([rawTerm, currentMode]) => {
93101
const term = rawTerm.toLowerCase().trim();
94102

95-
if (term.trim().length === 0) {
103+
if (term.length === 0) {
96104
return of(null);
97105
}
98106

99107
isSearching.next(true);
100108
track({ mode: currentMode });
101109

102-
const searchQuery = client.fetchQuery(
103-
modeToQuery(term, currentMode, config, false),
104-
);
105-
106-
if (currentMode === 'people' || currentMode === 'lists') {
107-
return searchQuery;
108-
}
109-
110-
const trendingQuery = client.fetchQuery(
111-
modeToTrendingQuery(term, currentMode),
112-
);
113-
114-
const exactQuery = client.fetchQuery(
115-
modeToQuery(term, currentMode, config, true),
116-
);
117-
return combineLatest([exactQuery, searchQuery, trendingQuery]).pipe(
118-
map(([exactResults, searchResults, trendingResults]) => ({
119-
type: 'media' as const,
120-
items: dedupe(
121-
(item) => item.key,
122-
(exactResults as MediaSearchResult).items,
123-
trendingResults?.items ?? [],
124-
(searchResults as MediaSearchResult).items,
125-
),
126-
})),
110+
return from(ensureFreshSearchKeys(config)).pipe(
111+
switchMap((freshConfig) => {
112+
const searchQuery = client.fetchQuery(
113+
modeToQuery(term, currentMode, freshConfig, false),
114+
);
115+
116+
if (currentMode === 'people' || currentMode === 'lists') {
117+
return searchQuery;
118+
}
119+
120+
const trendingQuery = client.fetchQuery(
121+
modeToTrendingQuery(term, currentMode),
122+
);
123+
124+
const exactQuery = client.fetchQuery(
125+
modeToQuery(term, currentMode, freshConfig, true),
126+
);
127+
return combineLatest([exactQuery, searchQuery, trendingQuery]).pipe(
128+
map(([exactResults, searchResults, trendingResults]) => ({
129+
type: 'media' as const,
130+
items: dedupe(
131+
(item) => item.key,
132+
(exactResults as MediaSearchResult).items,
133+
trendingResults?.items ?? [],
134+
(searchResults as MediaSearchResult).items,
135+
),
136+
})),
137+
);
138+
}),
139+
// Contain a failed lookup to this search. Without this the error
140+
// propagates through switchMap and terminates the whole stream, so
141+
// every later search silently returns nothing until remount.
142+
catchError(() => of(null)),
127143
);
128144
}),
129145
tap(() => isSearching.next(false)),
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { mintSearchKeysFromEnv } from '$lib/features/search/mintSearchKeysFromEnv.ts';
2+
import { json, type RequestHandler } from '@sveltejs/kit';
3+
4+
// Re-mints scoped search keys so long-lived tabs can refresh before the
5+
// short-lived token expires. Mirrors the mint in the root handle hook.
6+
export const GET: RequestHandler = () => {
7+
return json(mintSearchKeysFromEnv());
8+
};

0 commit comments

Comments
 (0)