Skip to content

Commit eb9e7a4

Browse files
committed
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 eb9e7a4

8 files changed

Lines changed: 150 additions & 79 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { z } from 'zod';
2+
3+
const TypesenseConfigSchema = z.object({
4+
keys: z.object({
5+
media: z.object({
6+
default: z.string(),
7+
exact: z.string(),
8+
}),
9+
people: z.string(),
10+
}),
11+
server: z.string(),
12+
});
13+
14+
export async function fetchSearchKeys(): Promise<TypesenseConfig> {
15+
const response = await fetch('/api/search-keys');
16+
if (!response.ok) {
17+
throw new Error(`Failed to fetch search keys: ${response.status}`);
18+
}
19+
return TypesenseConfigSchema.parse(await response.json());
20+
}
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
5+
// Hard caps baked into the scoped key. Typesense applies embedded params
6+
// itself and the client cannot override them, so these enforce a per-search
7+
// compute ceiling even if the transient token is replayed directly.
8+
const SEARCH_CUTOFF_MS = 250; // tune toward 100-150 once p99 search latency is confirmed
9+
const KEY_TTL = time.minutes(15);
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((Date.now() + KEY_TTL) / time.seconds(1));
21+
22+
const scope = (preset: string) =>
23+
typesense
24+
.keys()
25+
.generateScopedSearchKey(key, {
26+
preset,
27+
limit_hits: DEFAULT_SEARCH_LIMIT,
28+
per_page: DEFAULT_SEARCH_LIMIT,
29+
search_cutoff_ms: SEARCH_CUTOFF_MS,
30+
expires_at,
31+
});
32+
33+
return {
34+
keys: {
35+
media: {
36+
default: scope('search:media'),
37+
exact: scope('search:media:exact'),
38+
},
39+
people: scope('search:people'),
40+
},
41+
server,
42+
};
43+
}
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+
}

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

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ 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';
5+
import { time } from '$lib/utils/timing/time.ts';
6+
import { BehaviorSubject, combineLatest, from, of } from 'rxjs';
67
import { debounceTime, map, shareReplay, switchMap, tap } from 'rxjs/operators';
78
import type { SearchMode } from '../../requests/queries/search/models/SearchMode.ts';
89
import {
@@ -27,8 +28,13 @@ import { createBulkMediaIntl } from '../intl-overlay/createBulkMediaIntl.ts';
2728
import { getSearchContext } from './_internal/getSearchContext.ts';
2829
import { mapToSearchCover } from './_internal/mapToSearchCover.ts';
2930
import { postRecentSearch } from './_internal/postRecentSearch.ts';
31+
import { fetchSearchKeys } from './fetchSearchKeys.ts';
3032
import type { SearchResponse } from './models/SearchResponse.ts';
3133

34+
// Refresh the scoped keys before the server-side token TTL (15m) lapses, so a
35+
// tab left open still searches with a live key.
36+
const KEY_REFRESH_INTERVAL = time.minutes(12);
37+
3238
function modeToQuery(
3339
query: string,
3440
mode: SearchMode,
@@ -84,6 +90,32 @@ export function useSearch() {
8490

8591
const searchTerm$ = new BehaviorSubject<string>('');
8692

93+
let mintedAt = Date.now();
94+
let refreshPromise: Promise<TypesenseConfig> | null = null;
95+
96+
async function ensureFreshKeys(): Promise<TypesenseConfig> {
97+
const isStale = Date.now() - mintedAt >= KEY_REFRESH_INTERVAL;
98+
if (!isStale) {
99+
return config.value;
100+
}
101+
102+
// Reuse the in-flight refresh so concurrent searches share one request.
103+
if (!refreshPromise) {
104+
refreshPromise = fetchSearchKeys()
105+
.then((fresh) => {
106+
config.next(fresh);
107+
mintedAt = Date.now();
108+
return fresh;
109+
})
110+
.catch(() => config.value)
111+
.finally(() => {
112+
refreshPromise = null;
113+
});
114+
}
115+
116+
return refreshPromise;
117+
}
118+
87119
const results = client == null ? of(null) : combineLatest([
88120
searchTerm$,
89121
mode,
@@ -92,38 +124,42 @@ export function useSearch() {
92124
switchMap(([rawTerm, currentMode]) => {
93125
const term = rawTerm.toLowerCase().trim();
94126

95-
if (term.trim().length === 0) {
127+
if (term.length === 0) {
96128
return of(null);
97129
}
98130

99131
isSearching.next(true);
100132
track({ mode: currentMode });
101133

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-
})),
134+
return from(ensureFreshKeys()).pipe(
135+
switchMap((freshConfig) => {
136+
const searchQuery = client.fetchQuery(
137+
modeToQuery(term, currentMode, freshConfig, false),
138+
);
139+
140+
if (currentMode === 'people' || currentMode === 'lists') {
141+
return searchQuery;
142+
}
143+
144+
const trendingQuery = client.fetchQuery(
145+
modeToTrendingQuery(term, currentMode),
146+
);
147+
148+
const exactQuery = client.fetchQuery(
149+
modeToQuery(term, currentMode, freshConfig, true),
150+
);
151+
return combineLatest([exactQuery, searchQuery, trendingQuery]).pipe(
152+
map(([exactResults, searchResults, trendingResults]) => ({
153+
type: 'media' as const,
154+
items: dedupe(
155+
(item) => item.key,
156+
(exactResults as MediaSearchResult).items,
157+
trendingResults?.items ?? [],
158+
(searchResults as MediaSearchResult).items,
159+
),
160+
})),
161+
);
162+
}),
127163
);
128164
}),
129165
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)