@murumets-ee/search 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/dist/admin.d.mts +279 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/index.d.mts +315 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Elastic License 2.0 (ELv2)
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject
|
|
14
|
+
to the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set
|
|
20
|
+
of the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
+
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
+
is subject to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
+
for the software granted under these terms ends immediately. If your company
|
|
40
|
+
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
+
of your company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
+
you also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license
|
|
61
|
+
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your
|
|
64
|
+
licenses to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
+
the software the licensor makes available under these terms, including any
|
|
77
|
+
portion of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
+
the power to direct the management and policies of an entity (for example, by
|
|
86
|
+
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your
|
|
92
|
+
licenses.
|
|
93
|
+
|
|
94
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/dist/admin.d.mts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { AdminRoute } from "@murumets-ee/core";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/rate-limiter.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Fixed-window per-key rate limiter with bounded memory.
|
|
6
|
+
*
|
|
7
|
+
* Not as accurate as token-bucket under bursty traffic, but sufficient for
|
|
8
|
+
* the search-overload failure mode the mechanics layer is guarding against
|
|
9
|
+
* (D13 — "10 testers vs 1000 ad clicks"). Window resets cleanly; no decay
|
|
10
|
+
* math; eviction by insertion order keeps memory under `maxEntries`.
|
|
11
|
+
*/
|
|
12
|
+
interface RateLimit {
|
|
13
|
+
/** Tokens (requests) per window. */
|
|
14
|
+
tokens: number;
|
|
15
|
+
/** Window duration in milliseconds. */
|
|
16
|
+
intervalMs: number;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/types.d.ts
|
|
20
|
+
/**
|
|
21
|
+
* Generic search types — backend-agnostic.
|
|
22
|
+
*
|
|
23
|
+
* Designed up-front (PR 1 of PLAN-ECOMMERCE.md) so that subsequent backend
|
|
24
|
+
* packages (`@murumets-ee/search-postgres`, `@murumets-ee/search-elasticsearch`)
|
|
25
|
+
* implement a stable contract. Per D5 — query mode is explicit so `term` /
|
|
26
|
+
* `prefix` / `phrase` choices are caller-visible, not buried in scoring magic.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Capabilities a provider supports. Consumers gate UI on these (e.g. only
|
|
30
|
+
* render the facet panel when `capabilities.facets` is true).
|
|
31
|
+
*/
|
|
32
|
+
interface SearchCapabilities {
|
|
33
|
+
/** Provider supports full-text relevance ranking. */
|
|
34
|
+
fullText: boolean;
|
|
35
|
+
/** Provider returns relevance scores on results. */
|
|
36
|
+
ranking: boolean;
|
|
37
|
+
/** Provider returns facet aggregations alongside results. */
|
|
38
|
+
facets: boolean;
|
|
39
|
+
/** Provider supports fuzzy matching / typo tolerance. */
|
|
40
|
+
fuzzy: boolean;
|
|
41
|
+
/** Provider supports prefix matching. */
|
|
42
|
+
prefix: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Query mode. Matches D5 for parts catalog (term + prefix only) and leaves
|
|
46
|
+
* `phrase` as the implicit default for ranked text search.
|
|
47
|
+
*
|
|
48
|
+
* - `term` — exact equality match on a normalized identifier (replacement-codes,
|
|
49
|
+
* cart-add, deep links). Deterministic; no scoring.
|
|
50
|
+
* - `prefix` — starts-with match. Used by the search box / autocomplete.
|
|
51
|
+
* - `phrase` — full-text default. Provider-specific scoring.
|
|
52
|
+
*/
|
|
53
|
+
type SearchMode = 'term' | 'prefix' | 'phrase';
|
|
54
|
+
/**
|
|
55
|
+
* Active facet filters keyed by field name. Multiple values per field are OR'd;
|
|
56
|
+
* different fields are AND'd (the standard facet-panel UX).
|
|
57
|
+
*
|
|
58
|
+
* **Field names and values are arbitrary user-supplied strings.** The route
|
|
59
|
+
* handler caps the count per field and the number of distinct fields so the
|
|
60
|
+
* map can't grow unbounded, but does NOT validate that the field names match
|
|
61
|
+
* any whitelist — that is the **provider's responsibility**. A provider that
|
|
62
|
+
* blindly interpolates these into a SQL `WHERE` or ES query body opens a
|
|
63
|
+
* trivial injection vector. Providers MUST whitelist allowed fields against
|
|
64
|
+
* the resource's known schema before constructing any backend query.
|
|
65
|
+
*/
|
|
66
|
+
type FacetFilters = Readonly<Record<string, readonly string[]>>;
|
|
67
|
+
/** Search query input. The route handler normalises before calling a provider. */
|
|
68
|
+
interface SearchInput {
|
|
69
|
+
/** User-typed query. Already trimmed by the mechanics layer; never empty. */
|
|
70
|
+
query: string;
|
|
71
|
+
/** Query mode. Defaults to `phrase` if the provider supports it, else `term`. */
|
|
72
|
+
mode: SearchMode;
|
|
73
|
+
/** Page-size limit. Mechanics layer caps to `maxLimit` before this is set. */
|
|
74
|
+
limit: number;
|
|
75
|
+
/** Page offset (0-based). */
|
|
76
|
+
offset: number;
|
|
77
|
+
/** Active facet filters. Empty record when no filters are active. */
|
|
78
|
+
filters: FacetFilters;
|
|
79
|
+
/** BCP47 locale for translatable fields, when meaningful. */
|
|
80
|
+
locale?: string;
|
|
81
|
+
/** Authenticated caller — providers may scope by `user.id` or role. */
|
|
82
|
+
user: SearchUser;
|
|
83
|
+
}
|
|
84
|
+
/** Minimal authenticated-user shape passed into providers. */
|
|
85
|
+
interface SearchUser {
|
|
86
|
+
id: string;
|
|
87
|
+
/**
|
|
88
|
+
* Caller's role, when known. May be `undefined` even for authenticated
|
|
89
|
+
* callers (e.g. legacy users with no assigned role). Providers that scope
|
|
90
|
+
* by role must handle the `undefined` case explicitly — silently treating
|
|
91
|
+
* "no role" as "no access" is usually right; treating it as "all access"
|
|
92
|
+
* is a privilege-escalation vector.
|
|
93
|
+
*/
|
|
94
|
+
role?: string;
|
|
95
|
+
}
|
|
96
|
+
/** A single result row. Providers project entity rows / ES docs into this. */
|
|
97
|
+
interface SearchResultRow {
|
|
98
|
+
/** Stable id used by the consumer to navigate / select the row. */
|
|
99
|
+
id: string;
|
|
100
|
+
/** Headline / title rendered in the result list. */
|
|
101
|
+
label: string;
|
|
102
|
+
/** Optional secondary text. */
|
|
103
|
+
description?: string;
|
|
104
|
+
/** Optional URL the consumer can link to. */
|
|
105
|
+
url?: string;
|
|
106
|
+
/** Relevance score, when the provider declares `capabilities.ranking`. */
|
|
107
|
+
score?: number;
|
|
108
|
+
/** Provider-specific extras (consumer-typed). */
|
|
109
|
+
data?: Record<string, unknown>;
|
|
110
|
+
}
|
|
111
|
+
/** Bucket inside a facet aggregation. */
|
|
112
|
+
interface FacetBucket {
|
|
113
|
+
value: string;
|
|
114
|
+
count: number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One facet aggregation. ACL-respecting (D15) — the provider applies the
|
|
118
|
+
* same row-level filter used by the underlying query before aggregating.
|
|
119
|
+
*/
|
|
120
|
+
interface FacetAggregation {
|
|
121
|
+
field: string;
|
|
122
|
+
buckets: readonly FacetBucket[];
|
|
123
|
+
}
|
|
124
|
+
/** A search response. */
|
|
125
|
+
interface SearchResult {
|
|
126
|
+
rows: readonly SearchResultRow[];
|
|
127
|
+
/** Total matches before pagination. -1 means provider doesn't compute total. */
|
|
128
|
+
total: number;
|
|
129
|
+
/** Facet aggregations. Empty array if the provider doesn't support facets. */
|
|
130
|
+
facets: readonly FacetAggregation[];
|
|
131
|
+
/** Wall-clock duration of the underlying provider call (ms). */
|
|
132
|
+
durationMs: number;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Provider contract. Backends (`IlikeProvider`, `ElasticsearchProvider`, …)
|
|
136
|
+
* implement this. The `resource` is a logical key (e.g. `'orders'`, `'parts'`)
|
|
137
|
+
* — not an entity name. One entity may be exposed via several resources, and
|
|
138
|
+
* one resource may aggregate across multiple entities.
|
|
139
|
+
*/
|
|
140
|
+
interface SearchProvider {
|
|
141
|
+
/** Logical resource name. Used as registry key, route segment, and audit tag. */
|
|
142
|
+
readonly resource: string;
|
|
143
|
+
/**
|
|
144
|
+
* Permission resource for the framework `view` check on every request.
|
|
145
|
+
* Defaults to `resource`. Override when the search resource and the
|
|
146
|
+
* underlying entity permission diverge (e.g. resource `parts` is gated on
|
|
147
|
+
* `commerce.products:view`).
|
|
148
|
+
*/
|
|
149
|
+
readonly permissionResource?: string;
|
|
150
|
+
/** Capabilities flags. Drives UI affordances on the consumer side. */
|
|
151
|
+
readonly capabilities: SearchCapabilities;
|
|
152
|
+
/**
|
|
153
|
+
* Run the search. Throws on internal errors; the route handler maps them.
|
|
154
|
+
*
|
|
155
|
+
* `signal` aborts when the per-query timeout (mechanics layer) elapses or
|
|
156
|
+
* when the caller disconnects. Implementations should wire it into their
|
|
157
|
+
* underlying client (DB query, fetch, ES client) so abandoned work doesn't
|
|
158
|
+
* keep a connection occupied.
|
|
159
|
+
*/
|
|
160
|
+
search(input: SearchInput, signal: AbortSignal): Promise<SearchResult>;
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/mechanics.d.ts
|
|
164
|
+
/**
|
|
165
|
+
* Mechanics layer — the cross-cutting concerns wrapping every provider call.
|
|
166
|
+
*
|
|
167
|
+
* Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship
|
|
168
|
+
* in v1 because adding them later means rewriting every call site, and the
|
|
169
|
+
* "10 testers vs 1000 ad clicks" failure mode kills sites that ship search
|
|
170
|
+
* without them.
|
|
171
|
+
*
|
|
172
|
+
* Not in scope here: separate connection-pool budget. That's a deployment
|
|
173
|
+
* concern for the underlying DB / ES client; this package can't enforce it
|
|
174
|
+
* without hard-coding a backend. Documented at the route handler.
|
|
175
|
+
*/
|
|
176
|
+
interface MechanicsOptions {
|
|
177
|
+
/** Per-user fixed-window rate limit. Default: 60 / 60s. */
|
|
178
|
+
perUserRate?: RateLimit;
|
|
179
|
+
/** Per-IP fixed-window rate limit. Default: 120 / 60s. */
|
|
180
|
+
perIpRate?: RateLimit;
|
|
181
|
+
/** Per-query timeout in ms. Default: 5000. */
|
|
182
|
+
timeoutMs?: number;
|
|
183
|
+
/** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */
|
|
184
|
+
cacheTtlMs?: number;
|
|
185
|
+
/** Result-cache max entries. Default: 500. */
|
|
186
|
+
cacheMaxEntries?: number;
|
|
187
|
+
/** Collapse identical concurrent calls into one underlying request. Default: true. */
|
|
188
|
+
dedupe?: boolean;
|
|
189
|
+
/** Minimum query length after trim. Default: 2. */
|
|
190
|
+
minQueryLength?: number;
|
|
191
|
+
/** Maximum allowed `limit`. Default: 50. */
|
|
192
|
+
maxLimit?: number;
|
|
193
|
+
/** Default `limit` when caller omits. Default: 20. */
|
|
194
|
+
defaultLimit?: number;
|
|
195
|
+
/** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */
|
|
196
|
+
maxOffset?: number;
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/registry.d.ts
|
|
200
|
+
/**
|
|
201
|
+
* Resource-keyed provider registry.
|
|
202
|
+
*
|
|
203
|
+
* Per D12 — admin and public surfaces each carry their own registry instance.
|
|
204
|
+
* Provider classes are shared; instances and their scope filters are not.
|
|
205
|
+
* Keying by resource name and refusing collisions keeps two plugins from
|
|
206
|
+
* silently overwriting each other.
|
|
207
|
+
*/
|
|
208
|
+
declare class SearchRegistry {
|
|
209
|
+
private providers;
|
|
210
|
+
/**
|
|
211
|
+
* Register a provider. Throws on collision — search resources are
|
|
212
|
+
* deny-by-default and a duplicate registration is almost always a bug
|
|
213
|
+
* (two contributors picking the same name, or a plugin loaded twice).
|
|
214
|
+
*/
|
|
215
|
+
register(provider: SearchProvider): void;
|
|
216
|
+
/** Lookup. Returns `undefined` when no provider is registered for `resource`. */
|
|
217
|
+
get(resource: string): SearchProvider | undefined;
|
|
218
|
+
/** Membership check — useful for surface introspection. */
|
|
219
|
+
has(resource: string): boolean;
|
|
220
|
+
/** Stable list of registered providers. */
|
|
221
|
+
list(): readonly SearchProvider[];
|
|
222
|
+
/** Number of registered providers. */
|
|
223
|
+
get size(): number;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/admin/routes.d.ts
|
|
227
|
+
interface SearchRoutesConfig {
|
|
228
|
+
/** Consumer-built registry. Keep one instance per surface (admin / public). */
|
|
229
|
+
registry: SearchRegistry;
|
|
230
|
+
/** Optional override of the default mechanics config. */
|
|
231
|
+
mechanics?: MechanicsOptions;
|
|
232
|
+
/**
|
|
233
|
+
* Client-IP extractor — used as the bucket key for the per-IP rate limit.
|
|
234
|
+
*
|
|
235
|
+
* **Required for per-IP rate limiting to be effective.** When omitted, all
|
|
236
|
+
* callers share a single global IP bucket (`'0.0.0.0'`) and per-IP throttling
|
|
237
|
+
* is effectively disabled — the per-user limit still applies.
|
|
238
|
+
*
|
|
239
|
+
* Why no convenient default: every transport has its own contract.
|
|
240
|
+
* `x-forwarded-for` is *only* trustworthy if a controlled reverse proxy
|
|
241
|
+
* sets it; on a directly-exposed deployment, the header is attacker-supplied
|
|
242
|
+
* and a default that reads it would silently turn per-IP rate-limiting into
|
|
243
|
+
* per-arbitrary-string rate-limiting (i.e. bypassable). Forcing the consumer
|
|
244
|
+
* to choose keeps the security property explicit.
|
|
245
|
+
*
|
|
246
|
+
* Use {@link proxyClientIp} when running behind a single trusted reverse
|
|
247
|
+
* proxy (Vercel, Cloudflare, AWS ALB, fly.io). For multi-hop deployments,
|
|
248
|
+
* write a small wrapper that picks the first untrusted hop from XFF.
|
|
249
|
+
*/
|
|
250
|
+
getClientIp?: (req: Request) => string;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Helper for deployments behind a single trusted reverse proxy. Reads the
|
|
254
|
+
* client IP from `x-forwarded-for` (first hop) or `x-real-ip`, falling back
|
|
255
|
+
* to `'0.0.0.0'` when neither header is present.
|
|
256
|
+
*
|
|
257
|
+
* **Only safe when the application is unreachable except via a proxy you
|
|
258
|
+
* control that overwrites these headers on every request.** A directly-
|
|
259
|
+
* exposed deployment must NOT use this helper — the headers are then
|
|
260
|
+
* attacker-supplied and per-IP rate-limiting becomes meaningless.
|
|
261
|
+
*
|
|
262
|
+
* For more complex topologies (multi-hop, custom forwarding header like
|
|
263
|
+
* Cloudflare's `cf-connecting-ip`), write your own extractor.
|
|
264
|
+
*/
|
|
265
|
+
declare function proxyClientIp(req: Request): string;
|
|
266
|
+
/**
|
|
267
|
+
* Build the `AdminRoute` for search. Register inside a plugin's
|
|
268
|
+
* `server.routes` (or pass directly into the admin api handler):
|
|
269
|
+
*
|
|
270
|
+
* ```ts
|
|
271
|
+
* const registry = new SearchRegistry()
|
|
272
|
+
* registry.register(new IlikeProvider({ resource: 'orders', ... }))
|
|
273
|
+
* createAdminApiHandler({ ...config, routes: [searchRoutes({ registry })] })
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
declare function searchRoutes(config: SearchRoutesConfig): AdminRoute;
|
|
277
|
+
//#endregion
|
|
278
|
+
export { type SearchRoutesConfig, proxyClientIp, searchRoutes };
|
|
279
|
+
//# sourceMappingURL=admin.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/internal/rate-limiter.ts","../src/types.ts","../src/mechanics.ts","../src/registry.ts","../src/admin/routes.ts"],"mappings":";;;;;;AAQA;;;;;UAAiB,SAAA;;EAEf,MAAA;ECGe;EDDf,UAAA;AAAA;;;;;;AAJF;;;;;;;;ACKA;UAAiB,kBAAA;;EAEf,QAAA;EAAA;EAEA,OAAA;EAEA;EAAA,MAAA;EAIA;EAFA,KAAA;EAEM;EAAN,MAAA;AAAA;;;;AA0BF;;;;;AAGA;KAjBY,UAAA;;;;;;;;;;;;;KAcA,YAAA,GAAe,QAAA,CAAS,MAAA;;UAGnB,WAAA;EAcT;EAZN,KAAA;EAYgB;EAVhB,IAAA,EAAM,UAAA;EAcmB;EAZzB,KAAA;EAaA;EAXA,MAAA;EAuBe;EArBf,OAAA,EAAS,YAAA;;EAET,MAAA;EAqBA;EAnBA,IAAA,EAAM,UAAA;AAAA;;UAIS,UAAA;EACf,EAAA;EAwBO;;;AAIT;;;;EApBE,IAAA;AAAA;;UAIe,eAAA;EA2Bc;EAzB7B,EAAA;EAyBA;EAvBA,KAAA;EAuB6B;EArB7B,WAAA;EAyBe;EAvBf,GAAA;;EAEA,KAAA;EAsBA;EApBA,IAAA,GAAO,MAAA;AAAA;;UAIQ,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AA6BF;;;;AAAA,UAtBiB,gBAAA;EACf,KAAA;EACA,OAAA,WAAkB,WAAA;AAAA;;UAIH,YAAA;EACf,IAAA,WAAe,eAAA;EAwBN;EAtBT,KAAA;EAwBuB;EAtBvB,MAAA,WAAiB,gBAAA;EA+BH;EA7Bd,UAAA;AAAA;;;;;;;UASe,cAAA;;WAEN,QAAA;ECnHsB;;;;;;EAAA,SD0HtB,kBAAA;ECpHT;EAAA,SDsHS,YAAA,EAAc,kBAAA;EClHvB;;;;;;;;ED2HA,MAAA,CAAO,KAAA,EAAO,WAAA,EAAa,MAAA,EAAQ,WAAA,GAAc,OAAA,CAAQ,YAAA;AAAA;;;;AD9I3D;;;;;;;;ACKA;;;UCIiB,gBAAA;EDFf;ECIA,WAAA,GAAc,SAAA;EDAd;ECEA,SAAA,GAAY,SAAA;EDEZ;ECAA,SAAA;EDAM;ECEN,UAAA;EDUoB;ECRpB,eAAA;EDQoB;ECNpB,MAAA;EDoBU;EClBV,cAAA;;EAEA,QAAA;EDgBwC;ECdxC,YAAA;EDiB0B;ECf1B,SAAA;AAAA;;;;;AF7BF;;;;;;cGEa,cAAA;EAAA,QACH,SAAA;EFEO;;;;;EEKf,QAAA,CAAS,QAAA,EAAU,cAAA;EFCnB;EESA,GAAA,CAAI,QAAA,WAAmB,cAAA;EFLvB;EEUA,GAAA,CAAI,QAAA;EFVE;EEeN,IAAA,CAAA,YAAiB,cAAA;EFHG;EAAA,IEQhB,IAAA,CAAA;AAAA;;;UCrCW,kBAAA;EJES;EIAxB,QAAA,EAAU,cAAA;EJEV;EIAA,SAAA,GAAY,gBAAA;;;;AHGd;;;;;;;;;;;AAsBA;;;;EGNE,WAAA,IAAe,GAAA,EAAK,OAAA;AAAA;;;;;AHuBtB;;;;;;;;;iBGLgB,aAAA,CAAc,GAAA,EAAK,OAAA;;;;;;;;;;;iBAqBnB,YAAA,CAAa,MAAA,EAAQ,kBAAA,GAAqB,UAAA"}
|
package/dist/admin.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=class extends Error{status;code;constructor(e,t,n,r){super(e,r),this.name=`SearchError`,this.status=t,this.code=n}};const t={QueryTooShort:`query_too_short`,UnsupportedMode:`unsupported_mode`,InvalidPagination:`invalid_pagination`,RateLimited:`rate_limited`,Timeout:`timeout`};var n=class{buckets=new Map;tokens;intervalMs;maxEntries;constructor(e,t=1e4){if(e.tokens<=0)throw Error(`tokens must be > 0`);if(e.intervalMs<=0)throw Error(`intervalMs must be > 0`);if(t<=0)throw Error(`maxEntries must be > 0`);this.tokens=e.tokens,this.intervalMs=e.intervalMs,this.maxEntries=t}consume(e,t=Date.now()){let n=this.buckets.get(e);if((!n||t-n.windowStart>=this.intervalMs)&&(n={count:0,windowStart:t},this.buckets.set(e,n)),n.count>=this.tokens)return!1;for(n.count++;this.buckets.size>this.maxEntries;){let e=this.buckets.keys().next().value;if(e===void 0)break;this.buckets.delete(e)}return!0}get size(){return this.buckets.size}},r=class{entries=new Map;ttlMs;maxEntries;constructor(e,t){if(e<=0)throw Error(`ttlMs must be > 0`);if(t<=0)throw Error(`maxEntries must be > 0`);this.ttlMs=e,this.maxEntries=t}get(e,t=Date.now()){let n=this.entries.get(e);if(n){if(t>=n.expiresAt){this.entries.delete(e);return}return n.value}}set(e,t,n=Date.now()){for(this.entries.delete(e),this.entries.set(e,{value:t,expiresAt:n+this.ttlMs});this.entries.size>this.maxEntries;){let e=this.entries.keys().next().value;if(e===void 0)break;this.entries.delete(e)}}delete(e){this.entries.delete(e)}get size(){return this.entries.size}};const i={perUserRate:{tokens:60,intervalMs:6e4},perIpRate:{tokens:120,intervalMs:6e4},timeoutMs:5e3,cacheTtlMs:5e3,cacheMaxEntries:500,dedupe:!0,minQueryLength:2,maxLimit:50,defaultLimit:20,maxOffset:1e3},a=new Set([`term`,`prefix`,`phrase`]);var o=class o{opts;perUserLimiter;perIpLimiter;cache;inflight=new Map;static INFLIGHT_MAX=1e3;constructor(e={}){this.opts={perUserRate:e.perUserRate??i.perUserRate,perIpRate:e.perIpRate??i.perIpRate,timeoutMs:e.timeoutMs??i.timeoutMs,cacheTtlMs:e.cacheTtlMs??i.cacheTtlMs,cacheMaxEntries:e.cacheMaxEntries??i.cacheMaxEntries,dedupe:e.dedupe??i.dedupe,minQueryLength:e.minQueryLength??i.minQueryLength,maxLimit:e.maxLimit??i.maxLimit,defaultLimit:e.defaultLimit??i.defaultLimit,maxOffset:e.maxOffset??i.maxOffset},this.perUserLimiter=new n(this.opts.perUserRate),this.perIpLimiter=new n(this.opts.perIpRate),this.cache=new r(this.opts.cacheTtlMs,this.opts.cacheMaxEntries)}prepare(n){let r=(n.query??``).trim();if(r.length<this.opts.minQueryLength)throw new e(`Query must be at least ${this.opts.minQueryLength} characters`,400,t.QueryTooShort);return{query:r,mode:this.resolveMode(n.mode),limit:this.resolveLimit(n.limit),offset:this.resolveOffset(n.offset),filters:n.filters??{},user:n.user,...n.locale!==void 0&&{locale:n.locale}}}enforceRateLimit(n,r,i=Date.now()){if(!this.perUserLimiter.consume(`u:${n}`,i)||!this.perIpLimiter.consume(`ip:${r}`,i))throw new e(`Rate limit exceeded`,429,t.RateLimited)}async execute(e,t){let n=c(e.resource,t),r=this.cache.get(n);if(r)return r;let i=this.opts.dedupe&&this.inflight.size<o.INFLIGHT_MAX;if(this.opts.dedupe){let e=this.inflight.get(n);if(e)return e}let a=this.runWithTimeout(e,t).then(e=>(this.cache.set(n,e),e)).finally(()=>{i&&this.inflight.delete(n)});return i&&this.inflight.set(n,a),a}async runWithTimeout(n,r){let i=new AbortController,a=this.opts.timeoutMs,o,s=new Promise((n,r)=>{o=setTimeout(()=>{i.abort(),r(new e(`Search timed out after ${a}ms`,504,t.Timeout))},a)});try{return await Promise.race([n.search(r,i.signal),s])}catch(n){throw i.signal.aborted&&!(n instanceof e)?new e(`Search timed out after ${a}ms`,504,t.Timeout,{cause:n}):n}finally{o!==void 0&&clearTimeout(o)}}resolveMode(n){if(n==null||n===``)return`phrase`;if(!a.has(n))throw new e(`Unknown query mode '${n}'`,400,t.UnsupportedMode);return n}resolveLimit(n){if(n==null||n===``)return this.opts.defaultLimit;let r=s(n);if(r===null||r<=0)throw new e(`limit must be a positive integer`,400,t.InvalidPagination);return Math.min(r,this.opts.maxLimit)}resolveOffset(n){if(n==null||n===``)return 0;let r=s(n);if(r===null||r<0)throw new e(`offset must be a non-negative integer`,400,t.InvalidPagination);if(r>this.opts.maxOffset)throw new e(`offset must be <= ${this.opts.maxOffset}`,400,t.InvalidPagination);return r}};function s(e){if(typeof e==`number`)return Number.isFinite(e)&&Number.isInteger(e)?e:null;if(!/^-?\d+$/.test(e))return null;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:null}function c(e,t){let n=Object.keys(t.filters).sort().map(e=>`${e}=${[...t.filters[e]??[]].sort().join(`,`)}`).join(`|`);return[e,t.user.id,t.locale??``,t.mode,t.limit,t.offset,t.query,n].join(`\0`)}const l=`0.0.0.0`;function u(e){let t=e.headers.get(`x-forwarded-for`);if(t){let e=t.split(`,`)[0]?.trim();if(e)return e}let n=e.headers.get(`x-real-ip`);return n?n.trim():l}function d(t){let n=new o(t.mechanics),r=t.getClientIp??(()=>l);return{prefix:`search`,handlers:{GET:async(i,a)=>{let o=a.segments[0];if(!o)return m(`Missing search resource`,400,`missing_resource`);if(a.segments.length>1)return m(`Search route does not support sub-paths`,404,`not_found`);let s=t.registry.get(o);if(!s)return m(`Search resource '${o}' not found`,404,`not_found`);let c=s.permissionResource??s.resource;if(!a.checkPermission(c,`view`))return m(`Search resource '${o}' not found`,404,`not_found`);let l=new URL(i.url),u={id:a.user.id,...a.user.role!==void 0&&{role:a.user.role}};try{let e=n.prepare({query:l.searchParams.get(`q`),mode:l.searchParams.get(`mode`),limit:l.searchParams.get(`limit`),offset:l.searchParams.get(`offset`),filters:f(l.searchParams),...a.locale!==void 0&&{locale:a.locale},user:u});n.enforceRateLimit(a.user.id,r(i));let t=await n.execute(s,e);return p({resource:s.resource,capabilities:s.capabilities,rows:t.rows,total:t.total,facets:t.facets,durationMs:t.durationMs})}catch(t){if(t instanceof e)return m(t.message,t.status,t.code);throw t}}}}}function f(e){let t={};for(let[n,r]of e.entries()){if(!n.startsWith(`f.`))continue;let e=n.slice(2);if(!e||r===``)continue;let i=t[e];if(!i&&Object.keys(t).length>=16)continue;let a=i??[];a.length>=16||(a.push(r),t[e]=a)}return t}function p(e){return new Response(JSON.stringify(e),{status:200,headers:{"content-type":`application/json; charset=utf-8`}})}function m(e,t,n){return new Response(JSON.stringify({error:e,code:n}),{status:t,headers:{"content-type":`application/json; charset=utf-8`}})}export{u as proxyClientIp,d as searchRoutes};
|
|
2
|
+
//# sourceMappingURL=admin.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.mjs","names":[],"sources":["../src/errors.ts","../src/internal/rate-limiter.ts","../src/internal/ttl-cache.ts","../src/mechanics.ts","../src/admin/routes.ts"],"sourcesContent":["/**\n * Mechanics-layer errors. The admin route handler maps these to HTTP\n * statuses; tests assert the `code` constants are stable.\n *\n * Optionally carries a `cause` (ES2022) so the outer admin handler's logger\n * can surface the underlying provider error when one is folded into a\n * higher-level code (e.g. provider rejection arriving after abort gets\n * mapped to `Timeout`, but the original failure is preserved as `cause`).\n */\nexport class SearchError extends Error {\n readonly status: number\n readonly code: string\n constructor(message: string, status: number, code: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'SearchError'\n this.status = status\n this.code = code\n }\n}\n\nexport const SearchErrorCodes = {\n /** Query did not meet `minQueryLength`. */\n QueryTooShort: 'query_too_short',\n /** Query mode unrecognised or unsupported by provider. */\n UnsupportedMode: 'unsupported_mode',\n /** Pagination out of bounds (limit/offset too large or negative). */\n InvalidPagination: 'invalid_pagination',\n /** Per-user or per-IP rate limit exceeded. */\n RateLimited: 'rate_limited',\n /** Provider call exceeded `timeoutMs`. */\n Timeout: 'timeout',\n} as const\n","/**\n * Fixed-window per-key rate limiter with bounded memory.\n *\n * Not as accurate as token-bucket under bursty traffic, but sufficient for\n * the search-overload failure mode the mechanics layer is guarding against\n * (D13 — \"10 testers vs 1000 ad clicks\"). Window resets cleanly; no decay\n * math; eviction by insertion order keeps memory under `maxEntries`.\n */\nexport interface RateLimit {\n /** Tokens (requests) per window. */\n tokens: number\n /** Window duration in milliseconds. */\n intervalMs: number\n}\n\nexport class RateLimiter {\n private buckets = new Map<string, { count: number; windowStart: number }>()\n private readonly tokens: number\n private readonly intervalMs: number\n private readonly maxEntries: number\n\n constructor(limit: RateLimit, maxEntries: number = 10_000) {\n if (limit.tokens <= 0) throw new Error('tokens must be > 0')\n if (limit.intervalMs <= 0) throw new Error('intervalMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.tokens = limit.tokens\n this.intervalMs = limit.intervalMs\n this.maxEntries = maxEntries\n }\n\n /**\n * Try to consume one token for `key`. Returns `true` if allowed, `false`\n * if the window's quota has been exhausted.\n */\n consume(key: string, now: number = Date.now()): boolean {\n let entry = this.buckets.get(key)\n if (!entry || now - entry.windowStart >= this.intervalMs) {\n entry = { count: 0, windowStart: now }\n this.buckets.set(key, entry)\n }\n if (entry.count >= this.tokens) return false\n entry.count++\n // Insertion-order eviction (FIFO). When eviction does kick in, the\n // oldest entry is dropped — which *resets* that key's window on the\n // next call (granting a fresh quota). That's the safe direction:\n // attackers rotating through keys can't use eviction to suppress\n // legitimate users below their limit. Switching to LRU would be\n // exploitable here.\n while (this.buckets.size > this.maxEntries) {\n const oldest = this.buckets.keys().next().value\n if (oldest === undefined) break\n this.buckets.delete(oldest)\n }\n return true\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.buckets.size\n }\n}\n","/**\n * Bounded TTL cache. Insertion-order-based eviction (Maps preserve it) —\n * a \"first-in, first-out\" approximation of LRU that's good enough for the\n * short-TTL hot-query smoothing the search layer needs without paying for\n * a full LRU implementation.\n *\n * Not exported from the package — provider implementations and tests use\n * the higher-level mechanics class.\n */\nexport class TtlCache<V> {\n private entries = new Map<string, { value: V; expiresAt: number }>()\n private readonly ttlMs: number\n private readonly maxEntries: number\n\n constructor(ttlMs: number, maxEntries: number) {\n if (ttlMs <= 0) throw new Error('ttlMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.ttlMs = ttlMs\n this.maxEntries = maxEntries\n }\n\n get(key: string, now: number = Date.now()): V | undefined {\n const entry = this.entries.get(key)\n if (!entry) return undefined\n if (now >= entry.expiresAt) {\n this.entries.delete(key)\n return undefined\n }\n return entry.value\n }\n\n set(key: string, value: V, now: number = Date.now()): void {\n // Re-insert to refresh insertion order — ensures recently-set keys\n // outlive older ones under eviction pressure.\n this.entries.delete(key)\n this.entries.set(key, { value, expiresAt: now + this.ttlMs })\n while (this.entries.size > this.maxEntries) {\n const oldest = this.entries.keys().next().value\n if (oldest === undefined) break\n this.entries.delete(oldest)\n }\n }\n\n delete(key: string): void {\n this.entries.delete(key)\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.entries.size\n }\n}\n","import { SearchError, SearchErrorCodes } from './errors'\nimport { RateLimiter, type RateLimit } from './internal/rate-limiter'\nimport { TtlCache } from './internal/ttl-cache'\nimport type { FacetFilters, SearchInput, SearchMode, SearchProvider, SearchResult, SearchUser } from './types'\n\n/**\n * Mechanics layer — the cross-cutting concerns wrapping every provider call.\n *\n * Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship\n * in v1 because adding them later means rewriting every call site, and the\n * \"10 testers vs 1000 ad clicks\" failure mode kills sites that ship search\n * without them.\n *\n * Not in scope here: separate connection-pool budget. That's a deployment\n * concern for the underlying DB / ES client; this package can't enforce it\n * without hard-coding a backend. Documented at the route handler.\n */\nexport interface MechanicsOptions {\n /** Per-user fixed-window rate limit. Default: 60 / 60s. */\n perUserRate?: RateLimit\n /** Per-IP fixed-window rate limit. Default: 120 / 60s. */\n perIpRate?: RateLimit\n /** Per-query timeout in ms. Default: 5000. */\n timeoutMs?: number\n /** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */\n cacheTtlMs?: number\n /** Result-cache max entries. Default: 500. */\n cacheMaxEntries?: number\n /** Collapse identical concurrent calls into one underlying request. Default: true. */\n dedupe?: boolean\n /** Minimum query length after trim. Default: 2. */\n minQueryLength?: number\n /** Maximum allowed `limit`. Default: 50. */\n maxLimit?: number\n /** Default `limit` when caller omits. Default: 20. */\n defaultLimit?: number\n /** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */\n maxOffset?: number\n}\n\ninterface ResolvedMechanics {\n perUserRate: RateLimit\n perIpRate: RateLimit\n timeoutMs: number\n cacheTtlMs: number\n cacheMaxEntries: number\n dedupe: boolean\n minQueryLength: number\n maxLimit: number\n defaultLimit: number\n maxOffset: number\n}\n\n/**\n * Resolved mechanics defaults.\n *\n * Exported for consumer introspection — admin UIs that want to render\n * \"max 50 results\" hints can read from a single source of truth.\n */\nexport const DEFAULT_MECHANICS: ResolvedMechanics = {\n perUserRate: { tokens: 60, intervalMs: 60_000 },\n perIpRate: { tokens: 120, intervalMs: 60_000 },\n timeoutMs: 5_000,\n cacheTtlMs: 5_000,\n cacheMaxEntries: 500,\n dedupe: true,\n minQueryLength: 2,\n maxLimit: 50,\n defaultLimit: 20,\n maxOffset: 1_000,\n}\n\n/** Raw query parameters as the route handler parses them off the URL/body. */\nexport interface RawSearchParams {\n query: string | null | undefined\n mode?: string | null | undefined\n limit?: string | number | null | undefined\n offset?: string | number | null | undefined\n filters?: FacetFilters\n locale?: string\n user: SearchUser\n}\n\nconst VALID_MODES: ReadonlySet<SearchMode> = new Set<SearchMode>(['term', 'prefix', 'phrase'])\n\nexport class SearchMechanics {\n private readonly opts: ResolvedMechanics\n private readonly perUserLimiter: RateLimiter\n private readonly perIpLimiter: RateLimiter\n private readonly cache: TtlCache<SearchResult>\n // Per-user partitioning happens at the fingerprint level (user.id is\n // included), so two callers with different identities never share an\n // entry here even when their query strings collide.\n private readonly inflight = new Map<string, Promise<SearchResult>>()\n /**\n * Soft cap on in-flight dedupe entries. Once exceeded, new calls run\n * uncoordinated rather than wait — refusing to dedupe is always safe;\n * letting the map grow unbounded is not.\n */\n private static readonly INFLIGHT_MAX = 1_000\n\n constructor(options: MechanicsOptions = {}) {\n this.opts = {\n perUserRate: options.perUserRate ?? DEFAULT_MECHANICS.perUserRate,\n perIpRate: options.perIpRate ?? DEFAULT_MECHANICS.perIpRate,\n timeoutMs: options.timeoutMs ?? DEFAULT_MECHANICS.timeoutMs,\n cacheTtlMs: options.cacheTtlMs ?? DEFAULT_MECHANICS.cacheTtlMs,\n cacheMaxEntries: options.cacheMaxEntries ?? DEFAULT_MECHANICS.cacheMaxEntries,\n dedupe: options.dedupe ?? DEFAULT_MECHANICS.dedupe,\n minQueryLength: options.minQueryLength ?? DEFAULT_MECHANICS.minQueryLength,\n maxLimit: options.maxLimit ?? DEFAULT_MECHANICS.maxLimit,\n defaultLimit: options.defaultLimit ?? DEFAULT_MECHANICS.defaultLimit,\n maxOffset: options.maxOffset ?? DEFAULT_MECHANICS.maxOffset,\n }\n this.perUserLimiter = new RateLimiter(this.opts.perUserRate)\n this.perIpLimiter = new RateLimiter(this.opts.perIpRate)\n this.cache = new TtlCache<SearchResult>(this.opts.cacheTtlMs, this.opts.cacheMaxEntries)\n }\n\n /**\n * Validate + normalize raw params into a `SearchInput` or throw `SearchError`.\n * Pure function: no side effects, no rate-limit consumption.\n */\n prepare(raw: RawSearchParams): SearchInput {\n const query = (raw.query ?? '').trim()\n if (query.length < this.opts.minQueryLength) {\n throw new SearchError(\n `Query must be at least ${this.opts.minQueryLength} characters`,\n 400,\n SearchErrorCodes.QueryTooShort,\n )\n }\n\n const mode = this.resolveMode(raw.mode)\n const limit = this.resolveLimit(raw.limit)\n const offset = this.resolveOffset(raw.offset)\n const filters = raw.filters ?? {}\n\n return {\n query,\n mode,\n limit,\n offset,\n filters,\n user: raw.user,\n ...(raw.locale !== undefined && { locale: raw.locale }),\n }\n }\n\n /**\n * Enforce per-user + per-IP rate limits. Throws `SearchError(429)` on exceed.\n *\n * Both limits must pass — keeps a single user from masking IP-level abuse,\n * and an aggregate IP cap from being saturated by one chatty user.\n */\n enforceRateLimit(userId: string, ip: string, now: number = Date.now()): void {\n if (!this.perUserLimiter.consume(`u:${userId}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n if (!this.perIpLimiter.consume(`ip:${ip}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n }\n\n /**\n * Run a provider through cache + dedupe + timeout. Caller is expected to\n * have already called `prepare()` and `enforceRateLimit()` (and any\n * permission gating) before reaching this.\n */\n async execute(provider: SearchProvider, input: SearchInput): Promise<SearchResult> {\n const key = fingerprint(provider.resource, input)\n\n const cached = this.cache.get(key)\n if (cached) return cached\n\n const dedupeActive = this.opts.dedupe && this.inflight.size < SearchMechanics.INFLIGHT_MAX\n if (this.opts.dedupe) {\n const inflight = this.inflight.get(key)\n if (inflight) return inflight\n }\n\n const promise = this.runWithTimeout(provider, input)\n .then((result) => {\n this.cache.set(key, result)\n return result\n })\n .finally(() => {\n if (dedupeActive) this.inflight.delete(key)\n })\n\n if (dedupeActive) this.inflight.set(key, promise)\n return promise\n }\n\n private async runWithTimeout(\n provider: SearchProvider,\n input: SearchInput,\n ): Promise<SearchResult> {\n const controller = new AbortController()\n const timeoutMs = this.opts.timeoutMs\n\n // Wall-clock guarantee — a misbehaving provider that ignores the abort\n // signal can't keep the request hanging. The `signal` is still passed in\n // so well-behaved providers cancel underlying work; the race ensures the\n // route returns to the caller within `timeoutMs` regardless.\n let timer: ReturnType<typeof setTimeout> | undefined\n const timeoutPromise = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n controller.abort()\n reject(\n new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n ),\n )\n }, timeoutMs)\n })\n\n try {\n return await Promise.race([provider.search(input, controller.signal), timeoutPromise])\n } catch (err) {\n // Provider rejected. If the timer already fired, fold the rejection into\n // a typed timeout error so the route maps cleanly to 504. Preserve the\n // original error as `cause` so the outer admin handler's logger surfaces\n // it during incident review (otherwise a real provider failure that\n // happened to arrive after abort would be silently re-labelled).\n if (controller.signal.aborted && !(err instanceof SearchError)) {\n throw new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n { cause: err },\n )\n }\n throw err\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n }\n\n private resolveMode(raw: string | null | undefined): SearchMode {\n if (raw === undefined || raw === null || raw === '') return 'phrase'\n if (!VALID_MODES.has(raw as SearchMode)) {\n throw new SearchError(`Unknown query mode '${raw}'`, 400, SearchErrorCodes.UnsupportedMode)\n }\n return raw as SearchMode\n }\n\n private resolveLimit(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return this.opts.defaultLimit\n const n = parseStrictInt(raw)\n if (n === null || n <= 0) {\n throw new SearchError('limit must be a positive integer', 400, SearchErrorCodes.InvalidPagination)\n }\n return Math.min(n, this.opts.maxLimit)\n }\n\n private resolveOffset(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return 0\n const n = parseStrictInt(raw)\n if (n === null || n < 0) {\n throw new SearchError('offset must be a non-negative integer', 400, SearchErrorCodes.InvalidPagination)\n }\n if (n > this.opts.maxOffset) {\n throw new SearchError(\n `offset must be <= ${this.opts.maxOffset}`,\n 400,\n SearchErrorCodes.InvalidPagination,\n )\n }\n return n\n }\n}\n\n/**\n * Strict integer parser — rejects `'1.5'`, `'1abc'`, `' '`. Returns `null`\n * when the input isn't a whole integer. Numbers go through the same shape\n * check via `Number.isInteger`.\n */\nfunction parseStrictInt(raw: string | number): number | null {\n if (typeof raw === 'number') {\n return Number.isFinite(raw) && Number.isInteger(raw) ? raw : null\n }\n if (!/^-?\\d+$/.test(raw)) return null\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : null\n}\n\n/**\n * Stable fingerprint for cache + dedupe keying. Includes user id since\n * provider results are role/scope-sensitive — two users firing the same\n * query must not see each other's filtered output.\n */\nfunction fingerprint(resource: string, input: SearchInput): string {\n const filters = Object.keys(input.filters)\n .sort()\n .map((k) => `${k}=${[...(input.filters[k] ?? [])].sort().join(',')}`)\n .join('|')\n return [\n resource,\n input.user.id,\n input.locale ?? '',\n input.mode,\n input.limit,\n input.offset,\n input.query,\n filters,\n ].join('\u0000')\n}\n","import type { AdminRoute, AdminRouteHandler } from '@murumets-ee/core'\nimport { SearchError } from '../errors'\nimport { type MechanicsOptions, SearchMechanics } from '../mechanics'\nimport type { SearchRegistry } from '../registry'\nimport type { FacetFilters, SearchProvider, SearchResult, SearchUser } from '../types'\n\nexport interface SearchRoutesConfig {\n /** Consumer-built registry. Keep one instance per surface (admin / public). */\n registry: SearchRegistry\n /** Optional override of the default mechanics config. */\n mechanics?: MechanicsOptions\n /**\n * Client-IP extractor — used as the bucket key for the per-IP rate limit.\n *\n * **Required for per-IP rate limiting to be effective.** When omitted, all\n * callers share a single global IP bucket (`'0.0.0.0'`) and per-IP throttling\n * is effectively disabled — the per-user limit still applies.\n *\n * Why no convenient default: every transport has its own contract.\n * `x-forwarded-for` is *only* trustworthy if a controlled reverse proxy\n * sets it; on a directly-exposed deployment, the header is attacker-supplied\n * and a default that reads it would silently turn per-IP rate-limiting into\n * per-arbitrary-string rate-limiting (i.e. bypassable). Forcing the consumer\n * to choose keeps the security property explicit.\n *\n * Use {@link proxyClientIp} when running behind a single trusted reverse\n * proxy (Vercel, Cloudflare, AWS ALB, fly.io). For multi-hop deployments,\n * write a small wrapper that picks the first untrusted hop from XFF.\n */\n getClientIp?: (req: Request) => string\n}\n\nconst NO_IP = '0.0.0.0'\n\n/**\n * Helper for deployments behind a single trusted reverse proxy. Reads the\n * client IP from `x-forwarded-for` (first hop) or `x-real-ip`, falling back\n * to `'0.0.0.0'` when neither header is present.\n *\n * **Only safe when the application is unreachable except via a proxy you\n * control that overwrites these headers on every request.** A directly-\n * exposed deployment must NOT use this helper — the headers are then\n * attacker-supplied and per-IP rate-limiting becomes meaningless.\n *\n * For more complex topologies (multi-hop, custom forwarding header like\n * Cloudflare's `cf-connecting-ip`), write your own extractor.\n */\nexport function proxyClientIp(req: Request): string {\n const xff = req.headers.get('x-forwarded-for')\n if (xff) {\n const first = xff.split(',')[0]?.trim()\n if (first) return first\n }\n const xri = req.headers.get('x-real-ip')\n if (xri) return xri.trim()\n return NO_IP\n}\n\n/**\n * Build the `AdminRoute` for search. Register inside a plugin's\n * `server.routes` (or pass directly into the admin api handler):\n *\n * ```ts\n * const registry = new SearchRegistry()\n * registry.register(new IlikeProvider({ resource: 'orders', ... }))\n * createAdminApiHandler({ ...config, routes: [searchRoutes({ registry })] })\n * ```\n */\nexport function searchRoutes(config: SearchRoutesConfig): AdminRoute {\n const mechanics = new SearchMechanics(config.mechanics)\n // No default fall-through to XFF: see SearchRoutesConfig.getClientIp doc\n // for why. When unset, all callers share a single global IP bucket.\n const getIp = config.getClientIp ?? ((): string => NO_IP)\n\n const handler: AdminRouteHandler = async (req, ctx) => {\n const resourceName = ctx.segments[0]\n if (!resourceName) {\n return jsonError('Missing search resource', 400, 'missing_resource')\n }\n if (ctx.segments.length > 1) {\n return jsonError('Search route does not support sub-paths', 404, 'not_found')\n }\n\n const provider = config.registry.get(resourceName)\n if (!provider) {\n // 404 — never leak which resources exist via 403 vs 404 differentiation.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const permissionResource = provider.permissionResource ?? provider.resource\n if (!ctx.checkPermission(permissionResource, 'view')) {\n // Mirror the 404-on-deny pattern from content-api: a forbidden search\n // resource is indistinguishable from a non-existent one. Avoids leaking\n // which resources are gated for the caller's role.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const url = new URL(req.url)\n const user: SearchUser = {\n id: ctx.user.id,\n ...(ctx.user.role !== undefined && { role: ctx.user.role }),\n }\n\n try {\n const input = mechanics.prepare({\n query: url.searchParams.get('q'),\n mode: url.searchParams.get('mode'),\n limit: url.searchParams.get('limit'),\n offset: url.searchParams.get('offset'),\n filters: parseFilters(url.searchParams),\n ...(ctx.locale !== undefined && { locale: ctx.locale }),\n user,\n })\n\n mechanics.enforceRateLimit(ctx.user.id, getIp(req))\n\n const result = await mechanics.execute(provider, input)\n return jsonOk({\n resource: provider.resource,\n capabilities: provider.capabilities,\n rows: result.rows,\n total: result.total,\n facets: result.facets,\n durationMs: result.durationMs,\n })\n } catch (err) {\n if (err instanceof SearchError) {\n return jsonError(err.message, err.status, err.code)\n }\n throw err\n }\n }\n\n return {\n prefix: 'search',\n // No `resource` — multi-resource handler does its own per-call check.\n handlers: { GET: handler },\n }\n}\n\n/**\n * Pull facet filters off the query string. Convention: `f.<field>=<value>`,\n * repeatable per field. Example: `?f.brand=Toyota&f.brand=Mercedes&f.year=2024`\n *\n * Two ceilings to bound the resulting map against crafted URLs:\n * - max 16 values per field (limits SQL/ES IN-list size)\n * - max 16 distinct fields (limits cache-fingerprint length and overall\n * map size; a real UI never approaches this)\n *\n * Field names and values are NOT validated against any schema here — that\n * is the provider's responsibility (see {@link FacetFilters} doc).\n */\nconst MAX_FILTER_VALUES_PER_FIELD = 16\nconst MAX_FILTER_FIELDS = 16\n\nfunction parseFilters(params: URLSearchParams): FacetFilters {\n const out: Record<string, string[]> = {}\n for (const [key, value] of params.entries()) {\n if (!key.startsWith('f.')) continue\n const field = key.slice(2)\n if (!field || value === '') continue\n const existing = out[field]\n if (!existing && Object.keys(out).length >= MAX_FILTER_FIELDS) continue\n const list = existing ?? []\n if (list.length >= MAX_FILTER_VALUES_PER_FIELD) continue\n list.push(value)\n out[field] = list\n }\n return out\n}\n\nfunction jsonOk(\n payload: {\n resource: string\n capabilities: SearchProvider['capabilities']\n rows: SearchResult['rows']\n total: number\n facets: SearchResult['facets']\n durationMs: number\n },\n): Response {\n return new Response(JSON.stringify(payload), {\n status: 200,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n\nfunction jsonError(message: string, status: number, code: string): Response {\n return new Response(JSON.stringify({ error: message, code }), {\n status,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n"],"mappings":"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"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Generic search types — backend-agnostic.
|
|
4
|
+
*
|
|
5
|
+
* Designed up-front (PR 1 of PLAN-ECOMMERCE.md) so that subsequent backend
|
|
6
|
+
* packages (`@murumets-ee/search-postgres`, `@murumets-ee/search-elasticsearch`)
|
|
7
|
+
* implement a stable contract. Per D5 — query mode is explicit so `term` /
|
|
8
|
+
* `prefix` / `phrase` choices are caller-visible, not buried in scoring magic.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Capabilities a provider supports. Consumers gate UI on these (e.g. only
|
|
12
|
+
* render the facet panel when `capabilities.facets` is true).
|
|
13
|
+
*/
|
|
14
|
+
interface SearchCapabilities {
|
|
15
|
+
/** Provider supports full-text relevance ranking. */
|
|
16
|
+
fullText: boolean;
|
|
17
|
+
/** Provider returns relevance scores on results. */
|
|
18
|
+
ranking: boolean;
|
|
19
|
+
/** Provider returns facet aggregations alongside results. */
|
|
20
|
+
facets: boolean;
|
|
21
|
+
/** Provider supports fuzzy matching / typo tolerance. */
|
|
22
|
+
fuzzy: boolean;
|
|
23
|
+
/** Provider supports prefix matching. */
|
|
24
|
+
prefix: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Query mode. Matches D5 for parts catalog (term + prefix only) and leaves
|
|
28
|
+
* `phrase` as the implicit default for ranked text search.
|
|
29
|
+
*
|
|
30
|
+
* - `term` — exact equality match on a normalized identifier (replacement-codes,
|
|
31
|
+
* cart-add, deep links). Deterministic; no scoring.
|
|
32
|
+
* - `prefix` — starts-with match. Used by the search box / autocomplete.
|
|
33
|
+
* - `phrase` — full-text default. Provider-specific scoring.
|
|
34
|
+
*/
|
|
35
|
+
type SearchMode = 'term' | 'prefix' | 'phrase';
|
|
36
|
+
/**
|
|
37
|
+
* Active facet filters keyed by field name. Multiple values per field are OR'd;
|
|
38
|
+
* different fields are AND'd (the standard facet-panel UX).
|
|
39
|
+
*
|
|
40
|
+
* **Field names and values are arbitrary user-supplied strings.** The route
|
|
41
|
+
* handler caps the count per field and the number of distinct fields so the
|
|
42
|
+
* map can't grow unbounded, but does NOT validate that the field names match
|
|
43
|
+
* any whitelist — that is the **provider's responsibility**. A provider that
|
|
44
|
+
* blindly interpolates these into a SQL `WHERE` or ES query body opens a
|
|
45
|
+
* trivial injection vector. Providers MUST whitelist allowed fields against
|
|
46
|
+
* the resource's known schema before constructing any backend query.
|
|
47
|
+
*/
|
|
48
|
+
type FacetFilters = Readonly<Record<string, readonly string[]>>;
|
|
49
|
+
/** Search query input. The route handler normalises before calling a provider. */
|
|
50
|
+
interface SearchInput {
|
|
51
|
+
/** User-typed query. Already trimmed by the mechanics layer; never empty. */
|
|
52
|
+
query: string;
|
|
53
|
+
/** Query mode. Defaults to `phrase` if the provider supports it, else `term`. */
|
|
54
|
+
mode: SearchMode;
|
|
55
|
+
/** Page-size limit. Mechanics layer caps to `maxLimit` before this is set. */
|
|
56
|
+
limit: number;
|
|
57
|
+
/** Page offset (0-based). */
|
|
58
|
+
offset: number;
|
|
59
|
+
/** Active facet filters. Empty record when no filters are active. */
|
|
60
|
+
filters: FacetFilters;
|
|
61
|
+
/** BCP47 locale for translatable fields, when meaningful. */
|
|
62
|
+
locale?: string;
|
|
63
|
+
/** Authenticated caller — providers may scope by `user.id` or role. */
|
|
64
|
+
user: SearchUser;
|
|
65
|
+
}
|
|
66
|
+
/** Minimal authenticated-user shape passed into providers. */
|
|
67
|
+
interface SearchUser {
|
|
68
|
+
id: string;
|
|
69
|
+
/**
|
|
70
|
+
* Caller's role, when known. May be `undefined` even for authenticated
|
|
71
|
+
* callers (e.g. legacy users with no assigned role). Providers that scope
|
|
72
|
+
* by role must handle the `undefined` case explicitly — silently treating
|
|
73
|
+
* "no role" as "no access" is usually right; treating it as "all access"
|
|
74
|
+
* is a privilege-escalation vector.
|
|
75
|
+
*/
|
|
76
|
+
role?: string;
|
|
77
|
+
}
|
|
78
|
+
/** A single result row. Providers project entity rows / ES docs into this. */
|
|
79
|
+
interface SearchResultRow {
|
|
80
|
+
/** Stable id used by the consumer to navigate / select the row. */
|
|
81
|
+
id: string;
|
|
82
|
+
/** Headline / title rendered in the result list. */
|
|
83
|
+
label: string;
|
|
84
|
+
/** Optional secondary text. */
|
|
85
|
+
description?: string;
|
|
86
|
+
/** Optional URL the consumer can link to. */
|
|
87
|
+
url?: string;
|
|
88
|
+
/** Relevance score, when the provider declares `capabilities.ranking`. */
|
|
89
|
+
score?: number;
|
|
90
|
+
/** Provider-specific extras (consumer-typed). */
|
|
91
|
+
data?: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
/** Bucket inside a facet aggregation. */
|
|
94
|
+
interface FacetBucket {
|
|
95
|
+
value: string;
|
|
96
|
+
count: number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One facet aggregation. ACL-respecting (D15) — the provider applies the
|
|
100
|
+
* same row-level filter used by the underlying query before aggregating.
|
|
101
|
+
*/
|
|
102
|
+
interface FacetAggregation {
|
|
103
|
+
field: string;
|
|
104
|
+
buckets: readonly FacetBucket[];
|
|
105
|
+
}
|
|
106
|
+
/** A search response. */
|
|
107
|
+
interface SearchResult {
|
|
108
|
+
rows: readonly SearchResultRow[];
|
|
109
|
+
/** Total matches before pagination. -1 means provider doesn't compute total. */
|
|
110
|
+
total: number;
|
|
111
|
+
/** Facet aggregations. Empty array if the provider doesn't support facets. */
|
|
112
|
+
facets: readonly FacetAggregation[];
|
|
113
|
+
/** Wall-clock duration of the underlying provider call (ms). */
|
|
114
|
+
durationMs: number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Provider contract. Backends (`IlikeProvider`, `ElasticsearchProvider`, …)
|
|
118
|
+
* implement this. The `resource` is a logical key (e.g. `'orders'`, `'parts'`)
|
|
119
|
+
* — not an entity name. One entity may be exposed via several resources, and
|
|
120
|
+
* one resource may aggregate across multiple entities.
|
|
121
|
+
*/
|
|
122
|
+
interface SearchProvider {
|
|
123
|
+
/** Logical resource name. Used as registry key, route segment, and audit tag. */
|
|
124
|
+
readonly resource: string;
|
|
125
|
+
/**
|
|
126
|
+
* Permission resource for the framework `view` check on every request.
|
|
127
|
+
* Defaults to `resource`. Override when the search resource and the
|
|
128
|
+
* underlying entity permission diverge (e.g. resource `parts` is gated on
|
|
129
|
+
* `commerce.products:view`).
|
|
130
|
+
*/
|
|
131
|
+
readonly permissionResource?: string;
|
|
132
|
+
/** Capabilities flags. Drives UI affordances on the consumer side. */
|
|
133
|
+
readonly capabilities: SearchCapabilities;
|
|
134
|
+
/**
|
|
135
|
+
* Run the search. Throws on internal errors; the route handler maps them.
|
|
136
|
+
*
|
|
137
|
+
* `signal` aborts when the per-query timeout (mechanics layer) elapses or
|
|
138
|
+
* when the caller disconnects. Implementations should wire it into their
|
|
139
|
+
* underlying client (DB query, fetch, ES client) so abandoned work doesn't
|
|
140
|
+
* keep a connection occupied.
|
|
141
|
+
*/
|
|
142
|
+
search(input: SearchInput, signal: AbortSignal): Promise<SearchResult>;
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/registry.d.ts
|
|
146
|
+
/**
|
|
147
|
+
* Resource-keyed provider registry.
|
|
148
|
+
*
|
|
149
|
+
* Per D12 — admin and public surfaces each carry their own registry instance.
|
|
150
|
+
* Provider classes are shared; instances and their scope filters are not.
|
|
151
|
+
* Keying by resource name and refusing collisions keeps two plugins from
|
|
152
|
+
* silently overwriting each other.
|
|
153
|
+
*/
|
|
154
|
+
declare class SearchRegistry {
|
|
155
|
+
private providers;
|
|
156
|
+
/**
|
|
157
|
+
* Register a provider. Throws on collision — search resources are
|
|
158
|
+
* deny-by-default and a duplicate registration is almost always a bug
|
|
159
|
+
* (two contributors picking the same name, or a plugin loaded twice).
|
|
160
|
+
*/
|
|
161
|
+
register(provider: SearchProvider): void;
|
|
162
|
+
/** Lookup. Returns `undefined` when no provider is registered for `resource`. */
|
|
163
|
+
get(resource: string): SearchProvider | undefined;
|
|
164
|
+
/** Membership check — useful for surface introspection. */
|
|
165
|
+
has(resource: string): boolean;
|
|
166
|
+
/** Stable list of registered providers. */
|
|
167
|
+
list(): readonly SearchProvider[];
|
|
168
|
+
/** Number of registered providers. */
|
|
169
|
+
get size(): number;
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/internal/rate-limiter.d.ts
|
|
173
|
+
/**
|
|
174
|
+
* Fixed-window per-key rate limiter with bounded memory.
|
|
175
|
+
*
|
|
176
|
+
* Not as accurate as token-bucket under bursty traffic, but sufficient for
|
|
177
|
+
* the search-overload failure mode the mechanics layer is guarding against
|
|
178
|
+
* (D13 — "10 testers vs 1000 ad clicks"). Window resets cleanly; no decay
|
|
179
|
+
* math; eviction by insertion order keeps memory under `maxEntries`.
|
|
180
|
+
*/
|
|
181
|
+
interface RateLimit {
|
|
182
|
+
/** Tokens (requests) per window. */
|
|
183
|
+
tokens: number;
|
|
184
|
+
/** Window duration in milliseconds. */
|
|
185
|
+
intervalMs: number;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/mechanics.d.ts
|
|
189
|
+
/**
|
|
190
|
+
* Mechanics layer — the cross-cutting concerns wrapping every provider call.
|
|
191
|
+
*
|
|
192
|
+
* Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship
|
|
193
|
+
* in v1 because adding them later means rewriting every call site, and the
|
|
194
|
+
* "10 testers vs 1000 ad clicks" failure mode kills sites that ship search
|
|
195
|
+
* without them.
|
|
196
|
+
*
|
|
197
|
+
* Not in scope here: separate connection-pool budget. That's a deployment
|
|
198
|
+
* concern for the underlying DB / ES client; this package can't enforce it
|
|
199
|
+
* without hard-coding a backend. Documented at the route handler.
|
|
200
|
+
*/
|
|
201
|
+
interface MechanicsOptions {
|
|
202
|
+
/** Per-user fixed-window rate limit. Default: 60 / 60s. */
|
|
203
|
+
perUserRate?: RateLimit;
|
|
204
|
+
/** Per-IP fixed-window rate limit. Default: 120 / 60s. */
|
|
205
|
+
perIpRate?: RateLimit;
|
|
206
|
+
/** Per-query timeout in ms. Default: 5000. */
|
|
207
|
+
timeoutMs?: number;
|
|
208
|
+
/** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */
|
|
209
|
+
cacheTtlMs?: number;
|
|
210
|
+
/** Result-cache max entries. Default: 500. */
|
|
211
|
+
cacheMaxEntries?: number;
|
|
212
|
+
/** Collapse identical concurrent calls into one underlying request. Default: true. */
|
|
213
|
+
dedupe?: boolean;
|
|
214
|
+
/** Minimum query length after trim. Default: 2. */
|
|
215
|
+
minQueryLength?: number;
|
|
216
|
+
/** Maximum allowed `limit`. Default: 50. */
|
|
217
|
+
maxLimit?: number;
|
|
218
|
+
/** Default `limit` when caller omits. Default: 20. */
|
|
219
|
+
defaultLimit?: number;
|
|
220
|
+
/** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */
|
|
221
|
+
maxOffset?: number;
|
|
222
|
+
}
|
|
223
|
+
interface ResolvedMechanics {
|
|
224
|
+
perUserRate: RateLimit;
|
|
225
|
+
perIpRate: RateLimit;
|
|
226
|
+
timeoutMs: number;
|
|
227
|
+
cacheTtlMs: number;
|
|
228
|
+
cacheMaxEntries: number;
|
|
229
|
+
dedupe: boolean;
|
|
230
|
+
minQueryLength: number;
|
|
231
|
+
maxLimit: number;
|
|
232
|
+
defaultLimit: number;
|
|
233
|
+
maxOffset: number;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolved mechanics defaults.
|
|
237
|
+
*
|
|
238
|
+
* Exported for consumer introspection — admin UIs that want to render
|
|
239
|
+
* "max 50 results" hints can read from a single source of truth.
|
|
240
|
+
*/
|
|
241
|
+
declare const DEFAULT_MECHANICS: ResolvedMechanics;
|
|
242
|
+
/** Raw query parameters as the route handler parses them off the URL/body. */
|
|
243
|
+
interface RawSearchParams {
|
|
244
|
+
query: string | null | undefined;
|
|
245
|
+
mode?: string | null | undefined;
|
|
246
|
+
limit?: string | number | null | undefined;
|
|
247
|
+
offset?: string | number | null | undefined;
|
|
248
|
+
filters?: FacetFilters;
|
|
249
|
+
locale?: string;
|
|
250
|
+
user: SearchUser;
|
|
251
|
+
}
|
|
252
|
+
declare class SearchMechanics {
|
|
253
|
+
private readonly opts;
|
|
254
|
+
private readonly perUserLimiter;
|
|
255
|
+
private readonly perIpLimiter;
|
|
256
|
+
private readonly cache;
|
|
257
|
+
private readonly inflight;
|
|
258
|
+
/**
|
|
259
|
+
* Soft cap on in-flight dedupe entries. Once exceeded, new calls run
|
|
260
|
+
* uncoordinated rather than wait — refusing to dedupe is always safe;
|
|
261
|
+
* letting the map grow unbounded is not.
|
|
262
|
+
*/
|
|
263
|
+
private static readonly INFLIGHT_MAX;
|
|
264
|
+
constructor(options?: MechanicsOptions);
|
|
265
|
+
/**
|
|
266
|
+
* Validate + normalize raw params into a `SearchInput` or throw `SearchError`.
|
|
267
|
+
* Pure function: no side effects, no rate-limit consumption.
|
|
268
|
+
*/
|
|
269
|
+
prepare(raw: RawSearchParams): SearchInput;
|
|
270
|
+
/**
|
|
271
|
+
* Enforce per-user + per-IP rate limits. Throws `SearchError(429)` on exceed.
|
|
272
|
+
*
|
|
273
|
+
* Both limits must pass — keeps a single user from masking IP-level abuse,
|
|
274
|
+
* and an aggregate IP cap from being saturated by one chatty user.
|
|
275
|
+
*/
|
|
276
|
+
enforceRateLimit(userId: string, ip: string, now?: number): void;
|
|
277
|
+
/**
|
|
278
|
+
* Run a provider through cache + dedupe + timeout. Caller is expected to
|
|
279
|
+
* have already called `prepare()` and `enforceRateLimit()` (and any
|
|
280
|
+
* permission gating) before reaching this.
|
|
281
|
+
*/
|
|
282
|
+
execute(provider: SearchProvider, input: SearchInput): Promise<SearchResult>;
|
|
283
|
+
private runWithTimeout;
|
|
284
|
+
private resolveMode;
|
|
285
|
+
private resolveLimit;
|
|
286
|
+
private resolveOffset;
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/errors.d.ts
|
|
290
|
+
/**
|
|
291
|
+
* Mechanics-layer errors. The admin route handler maps these to HTTP
|
|
292
|
+
* statuses; tests assert the `code` constants are stable.
|
|
293
|
+
*
|
|
294
|
+
* Optionally carries a `cause` (ES2022) so the outer admin handler's logger
|
|
295
|
+
* can surface the underlying provider error when one is folded into a
|
|
296
|
+
* higher-level code (e.g. provider rejection arriving after abort gets
|
|
297
|
+
* mapped to `Timeout`, but the original failure is preserved as `cause`).
|
|
298
|
+
*/
|
|
299
|
+
declare class SearchError extends Error {
|
|
300
|
+
readonly status: number;
|
|
301
|
+
readonly code: string;
|
|
302
|
+
constructor(message: string, status: number, code: string, options?: {
|
|
303
|
+
cause?: unknown;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
declare const SearchErrorCodes: {
|
|
307
|
+
/** Query did not meet `minQueryLength`. */readonly QueryTooShort: "query_too_short"; /** Query mode unrecognised or unsupported by provider. */
|
|
308
|
+
readonly UnsupportedMode: "unsupported_mode"; /** Pagination out of bounds (limit/offset too large or negative). */
|
|
309
|
+
readonly InvalidPagination: "invalid_pagination"; /** Per-user or per-IP rate limit exceeded. */
|
|
310
|
+
readonly RateLimited: "rate_limited"; /** Provider call exceeded `timeoutMs`. */
|
|
311
|
+
readonly Timeout: "timeout";
|
|
312
|
+
};
|
|
313
|
+
//#endregion
|
|
314
|
+
export { DEFAULT_MECHANICS, type FacetAggregation, type FacetBucket, type FacetFilters, type MechanicsOptions, type RawSearchParams, type SearchCapabilities, SearchError, SearchErrorCodes, type SearchInput, SearchMechanics, type SearchMode, type SearchProvider, SearchRegistry, type SearchResult, type SearchResultRow, type SearchUser };
|
|
315
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/registry.ts","../src/internal/rate-limiter.ts","../src/mechanics.ts","../src/errors.ts"],"mappings":"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"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=class{providers=new Map;register(e){if(this.providers.has(e.resource))throw Error(`Search provider for resource '${e.resource}' is already registered`);this.providers.set(e.resource,e)}get(e){return this.providers.get(e)}has(e){return this.providers.has(e)}list(){return Array.from(this.providers.values())}get size(){return this.providers.size}},t=class extends Error{status;code;constructor(e,t,n,r){super(e,r),this.name=`SearchError`,this.status=t,this.code=n}};const n={QueryTooShort:`query_too_short`,UnsupportedMode:`unsupported_mode`,InvalidPagination:`invalid_pagination`,RateLimited:`rate_limited`,Timeout:`timeout`};var r=class{buckets=new Map;tokens;intervalMs;maxEntries;constructor(e,t=1e4){if(e.tokens<=0)throw Error(`tokens must be > 0`);if(e.intervalMs<=0)throw Error(`intervalMs must be > 0`);if(t<=0)throw Error(`maxEntries must be > 0`);this.tokens=e.tokens,this.intervalMs=e.intervalMs,this.maxEntries=t}consume(e,t=Date.now()){let n=this.buckets.get(e);if((!n||t-n.windowStart>=this.intervalMs)&&(n={count:0,windowStart:t},this.buckets.set(e,n)),n.count>=this.tokens)return!1;for(n.count++;this.buckets.size>this.maxEntries;){let e=this.buckets.keys().next().value;if(e===void 0)break;this.buckets.delete(e)}return!0}get size(){return this.buckets.size}},i=class{entries=new Map;ttlMs;maxEntries;constructor(e,t){if(e<=0)throw Error(`ttlMs must be > 0`);if(t<=0)throw Error(`maxEntries must be > 0`);this.ttlMs=e,this.maxEntries=t}get(e,t=Date.now()){let n=this.entries.get(e);if(n){if(t>=n.expiresAt){this.entries.delete(e);return}return n.value}}set(e,t,n=Date.now()){for(this.entries.delete(e),this.entries.set(e,{value:t,expiresAt:n+this.ttlMs});this.entries.size>this.maxEntries;){let e=this.entries.keys().next().value;if(e===void 0)break;this.entries.delete(e)}}delete(e){this.entries.delete(e)}get size(){return this.entries.size}};const a={perUserRate:{tokens:60,intervalMs:6e4},perIpRate:{tokens:120,intervalMs:6e4},timeoutMs:5e3,cacheTtlMs:5e3,cacheMaxEntries:500,dedupe:!0,minQueryLength:2,maxLimit:50,defaultLimit:20,maxOffset:1e3},o=new Set([`term`,`prefix`,`phrase`]);var s=class e{opts;perUserLimiter;perIpLimiter;cache;inflight=new Map;static INFLIGHT_MAX=1e3;constructor(e={}){this.opts={perUserRate:e.perUserRate??a.perUserRate,perIpRate:e.perIpRate??a.perIpRate,timeoutMs:e.timeoutMs??a.timeoutMs,cacheTtlMs:e.cacheTtlMs??a.cacheTtlMs,cacheMaxEntries:e.cacheMaxEntries??a.cacheMaxEntries,dedupe:e.dedupe??a.dedupe,minQueryLength:e.minQueryLength??a.minQueryLength,maxLimit:e.maxLimit??a.maxLimit,defaultLimit:e.defaultLimit??a.defaultLimit,maxOffset:e.maxOffset??a.maxOffset},this.perUserLimiter=new r(this.opts.perUserRate),this.perIpLimiter=new r(this.opts.perIpRate),this.cache=new i(this.opts.cacheTtlMs,this.opts.cacheMaxEntries)}prepare(e){let r=(e.query??``).trim();if(r.length<this.opts.minQueryLength)throw new t(`Query must be at least ${this.opts.minQueryLength} characters`,400,n.QueryTooShort);return{query:r,mode:this.resolveMode(e.mode),limit:this.resolveLimit(e.limit),offset:this.resolveOffset(e.offset),filters:e.filters??{},user:e.user,...e.locale!==void 0&&{locale:e.locale}}}enforceRateLimit(e,r,i=Date.now()){if(!this.perUserLimiter.consume(`u:${e}`,i)||!this.perIpLimiter.consume(`ip:${r}`,i))throw new t(`Rate limit exceeded`,429,n.RateLimited)}async execute(t,n){let r=l(t.resource,n),i=this.cache.get(r);if(i)return i;let a=this.opts.dedupe&&this.inflight.size<e.INFLIGHT_MAX;if(this.opts.dedupe){let e=this.inflight.get(r);if(e)return e}let o=this.runWithTimeout(t,n).then(e=>(this.cache.set(r,e),e)).finally(()=>{a&&this.inflight.delete(r)});return a&&this.inflight.set(r,o),o}async runWithTimeout(e,r){let i=new AbortController,a=this.opts.timeoutMs,o,s=new Promise((e,r)=>{o=setTimeout(()=>{i.abort(),r(new t(`Search timed out after ${a}ms`,504,n.Timeout))},a)});try{return await Promise.race([e.search(r,i.signal),s])}catch(e){throw i.signal.aborted&&!(e instanceof t)?new t(`Search timed out after ${a}ms`,504,n.Timeout,{cause:e}):e}finally{o!==void 0&&clearTimeout(o)}}resolveMode(e){if(e==null||e===``)return`phrase`;if(!o.has(e))throw new t(`Unknown query mode '${e}'`,400,n.UnsupportedMode);return e}resolveLimit(e){if(e==null||e===``)return this.opts.defaultLimit;let r=c(e);if(r===null||r<=0)throw new t(`limit must be a positive integer`,400,n.InvalidPagination);return Math.min(r,this.opts.maxLimit)}resolveOffset(e){if(e==null||e===``)return 0;let r=c(e);if(r===null||r<0)throw new t(`offset must be a non-negative integer`,400,n.InvalidPagination);if(r>this.opts.maxOffset)throw new t(`offset must be <= ${this.opts.maxOffset}`,400,n.InvalidPagination);return r}};function c(e){if(typeof e==`number`)return Number.isFinite(e)&&Number.isInteger(e)?e:null;if(!/^-?\d+$/.test(e))return null;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:null}function l(e,t){let n=Object.keys(t.filters).sort().map(e=>`${e}=${[...t.filters[e]??[]].sort().join(`,`)}`).join(`|`);return[e,t.user.id,t.locale??``,t.mode,t.limit,t.offset,t.query,n].join(`\0`)}export{a as DEFAULT_MECHANICS,t as SearchError,n as SearchErrorCodes,s as SearchMechanics,e as SearchRegistry};
|
|
2
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/registry.ts","../src/errors.ts","../src/internal/rate-limiter.ts","../src/internal/ttl-cache.ts","../src/mechanics.ts"],"sourcesContent":["import type { SearchProvider } from './types'\n\n/**\n * Resource-keyed provider registry.\n *\n * Per D12 — admin and public surfaces each carry their own registry instance.\n * Provider classes are shared; instances and their scope filters are not.\n * Keying by resource name and refusing collisions keeps two plugins from\n * silently overwriting each other.\n */\nexport class SearchRegistry {\n private providers = new Map<string, SearchProvider>()\n\n /**\n * Register a provider. Throws on collision — search resources are\n * deny-by-default and a duplicate registration is almost always a bug\n * (two contributors picking the same name, or a plugin loaded twice).\n */\n register(provider: SearchProvider): void {\n if (this.providers.has(provider.resource)) {\n throw new Error(\n `Search provider for resource '${provider.resource}' is already registered`,\n )\n }\n this.providers.set(provider.resource, provider)\n }\n\n /** Lookup. Returns `undefined` when no provider is registered for `resource`. */\n get(resource: string): SearchProvider | undefined {\n return this.providers.get(resource)\n }\n\n /** Membership check — useful for surface introspection. */\n has(resource: string): boolean {\n return this.providers.has(resource)\n }\n\n /** Stable list of registered providers. */\n list(): readonly SearchProvider[] {\n return Array.from(this.providers.values())\n }\n\n /** Number of registered providers. */\n get size(): number {\n return this.providers.size\n }\n}\n","/**\n * Mechanics-layer errors. The admin route handler maps these to HTTP\n * statuses; tests assert the `code` constants are stable.\n *\n * Optionally carries a `cause` (ES2022) so the outer admin handler's logger\n * can surface the underlying provider error when one is folded into a\n * higher-level code (e.g. provider rejection arriving after abort gets\n * mapped to `Timeout`, but the original failure is preserved as `cause`).\n */\nexport class SearchError extends Error {\n readonly status: number\n readonly code: string\n constructor(message: string, status: number, code: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'SearchError'\n this.status = status\n this.code = code\n }\n}\n\nexport const SearchErrorCodes = {\n /** Query did not meet `minQueryLength`. */\n QueryTooShort: 'query_too_short',\n /** Query mode unrecognised or unsupported by provider. */\n UnsupportedMode: 'unsupported_mode',\n /** Pagination out of bounds (limit/offset too large or negative). */\n InvalidPagination: 'invalid_pagination',\n /** Per-user or per-IP rate limit exceeded. */\n RateLimited: 'rate_limited',\n /** Provider call exceeded `timeoutMs`. */\n Timeout: 'timeout',\n} as const\n","/**\n * Fixed-window per-key rate limiter with bounded memory.\n *\n * Not as accurate as token-bucket under bursty traffic, but sufficient for\n * the search-overload failure mode the mechanics layer is guarding against\n * (D13 — \"10 testers vs 1000 ad clicks\"). Window resets cleanly; no decay\n * math; eviction by insertion order keeps memory under `maxEntries`.\n */\nexport interface RateLimit {\n /** Tokens (requests) per window. */\n tokens: number\n /** Window duration in milliseconds. */\n intervalMs: number\n}\n\nexport class RateLimiter {\n private buckets = new Map<string, { count: number; windowStart: number }>()\n private readonly tokens: number\n private readonly intervalMs: number\n private readonly maxEntries: number\n\n constructor(limit: RateLimit, maxEntries: number = 10_000) {\n if (limit.tokens <= 0) throw new Error('tokens must be > 0')\n if (limit.intervalMs <= 0) throw new Error('intervalMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.tokens = limit.tokens\n this.intervalMs = limit.intervalMs\n this.maxEntries = maxEntries\n }\n\n /**\n * Try to consume one token for `key`. Returns `true` if allowed, `false`\n * if the window's quota has been exhausted.\n */\n consume(key: string, now: number = Date.now()): boolean {\n let entry = this.buckets.get(key)\n if (!entry || now - entry.windowStart >= this.intervalMs) {\n entry = { count: 0, windowStart: now }\n this.buckets.set(key, entry)\n }\n if (entry.count >= this.tokens) return false\n entry.count++\n // Insertion-order eviction (FIFO). When eviction does kick in, the\n // oldest entry is dropped — which *resets* that key's window on the\n // next call (granting a fresh quota). That's the safe direction:\n // attackers rotating through keys can't use eviction to suppress\n // legitimate users below their limit. Switching to LRU would be\n // exploitable here.\n while (this.buckets.size > this.maxEntries) {\n const oldest = this.buckets.keys().next().value\n if (oldest === undefined) break\n this.buckets.delete(oldest)\n }\n return true\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.buckets.size\n }\n}\n","/**\n * Bounded TTL cache. Insertion-order-based eviction (Maps preserve it) —\n * a \"first-in, first-out\" approximation of LRU that's good enough for the\n * short-TTL hot-query smoothing the search layer needs without paying for\n * a full LRU implementation.\n *\n * Not exported from the package — provider implementations and tests use\n * the higher-level mechanics class.\n */\nexport class TtlCache<V> {\n private entries = new Map<string, { value: V; expiresAt: number }>()\n private readonly ttlMs: number\n private readonly maxEntries: number\n\n constructor(ttlMs: number, maxEntries: number) {\n if (ttlMs <= 0) throw new Error('ttlMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.ttlMs = ttlMs\n this.maxEntries = maxEntries\n }\n\n get(key: string, now: number = Date.now()): V | undefined {\n const entry = this.entries.get(key)\n if (!entry) return undefined\n if (now >= entry.expiresAt) {\n this.entries.delete(key)\n return undefined\n }\n return entry.value\n }\n\n set(key: string, value: V, now: number = Date.now()): void {\n // Re-insert to refresh insertion order — ensures recently-set keys\n // outlive older ones under eviction pressure.\n this.entries.delete(key)\n this.entries.set(key, { value, expiresAt: now + this.ttlMs })\n while (this.entries.size > this.maxEntries) {\n const oldest = this.entries.keys().next().value\n if (oldest === undefined) break\n this.entries.delete(oldest)\n }\n }\n\n delete(key: string): void {\n this.entries.delete(key)\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.entries.size\n }\n}\n","import { SearchError, SearchErrorCodes } from './errors'\nimport { RateLimiter, type RateLimit } from './internal/rate-limiter'\nimport { TtlCache } from './internal/ttl-cache'\nimport type { FacetFilters, SearchInput, SearchMode, SearchProvider, SearchResult, SearchUser } from './types'\n\n/**\n * Mechanics layer — the cross-cutting concerns wrapping every provider call.\n *\n * Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship\n * in v1 because adding them later means rewriting every call site, and the\n * \"10 testers vs 1000 ad clicks\" failure mode kills sites that ship search\n * without them.\n *\n * Not in scope here: separate connection-pool budget. That's a deployment\n * concern for the underlying DB / ES client; this package can't enforce it\n * without hard-coding a backend. Documented at the route handler.\n */\nexport interface MechanicsOptions {\n /** Per-user fixed-window rate limit. Default: 60 / 60s. */\n perUserRate?: RateLimit\n /** Per-IP fixed-window rate limit. Default: 120 / 60s. */\n perIpRate?: RateLimit\n /** Per-query timeout in ms. Default: 5000. */\n timeoutMs?: number\n /** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */\n cacheTtlMs?: number\n /** Result-cache max entries. Default: 500. */\n cacheMaxEntries?: number\n /** Collapse identical concurrent calls into one underlying request. Default: true. */\n dedupe?: boolean\n /** Minimum query length after trim. Default: 2. */\n minQueryLength?: number\n /** Maximum allowed `limit`. Default: 50. */\n maxLimit?: number\n /** Default `limit` when caller omits. Default: 20. */\n defaultLimit?: number\n /** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */\n maxOffset?: number\n}\n\ninterface ResolvedMechanics {\n perUserRate: RateLimit\n perIpRate: RateLimit\n timeoutMs: number\n cacheTtlMs: number\n cacheMaxEntries: number\n dedupe: boolean\n minQueryLength: number\n maxLimit: number\n defaultLimit: number\n maxOffset: number\n}\n\n/**\n * Resolved mechanics defaults.\n *\n * Exported for consumer introspection — admin UIs that want to render\n * \"max 50 results\" hints can read from a single source of truth.\n */\nexport const DEFAULT_MECHANICS: ResolvedMechanics = {\n perUserRate: { tokens: 60, intervalMs: 60_000 },\n perIpRate: { tokens: 120, intervalMs: 60_000 },\n timeoutMs: 5_000,\n cacheTtlMs: 5_000,\n cacheMaxEntries: 500,\n dedupe: true,\n minQueryLength: 2,\n maxLimit: 50,\n defaultLimit: 20,\n maxOffset: 1_000,\n}\n\n/** Raw query parameters as the route handler parses them off the URL/body. */\nexport interface RawSearchParams {\n query: string | null | undefined\n mode?: string | null | undefined\n limit?: string | number | null | undefined\n offset?: string | number | null | undefined\n filters?: FacetFilters\n locale?: string\n user: SearchUser\n}\n\nconst VALID_MODES: ReadonlySet<SearchMode> = new Set<SearchMode>(['term', 'prefix', 'phrase'])\n\nexport class SearchMechanics {\n private readonly opts: ResolvedMechanics\n private readonly perUserLimiter: RateLimiter\n private readonly perIpLimiter: RateLimiter\n private readonly cache: TtlCache<SearchResult>\n // Per-user partitioning happens at the fingerprint level (user.id is\n // included), so two callers with different identities never share an\n // entry here even when their query strings collide.\n private readonly inflight = new Map<string, Promise<SearchResult>>()\n /**\n * Soft cap on in-flight dedupe entries. Once exceeded, new calls run\n * uncoordinated rather than wait — refusing to dedupe is always safe;\n * letting the map grow unbounded is not.\n */\n private static readonly INFLIGHT_MAX = 1_000\n\n constructor(options: MechanicsOptions = {}) {\n this.opts = {\n perUserRate: options.perUserRate ?? DEFAULT_MECHANICS.perUserRate,\n perIpRate: options.perIpRate ?? DEFAULT_MECHANICS.perIpRate,\n timeoutMs: options.timeoutMs ?? DEFAULT_MECHANICS.timeoutMs,\n cacheTtlMs: options.cacheTtlMs ?? DEFAULT_MECHANICS.cacheTtlMs,\n cacheMaxEntries: options.cacheMaxEntries ?? DEFAULT_MECHANICS.cacheMaxEntries,\n dedupe: options.dedupe ?? DEFAULT_MECHANICS.dedupe,\n minQueryLength: options.minQueryLength ?? DEFAULT_MECHANICS.minQueryLength,\n maxLimit: options.maxLimit ?? DEFAULT_MECHANICS.maxLimit,\n defaultLimit: options.defaultLimit ?? DEFAULT_MECHANICS.defaultLimit,\n maxOffset: options.maxOffset ?? DEFAULT_MECHANICS.maxOffset,\n }\n this.perUserLimiter = new RateLimiter(this.opts.perUserRate)\n this.perIpLimiter = new RateLimiter(this.opts.perIpRate)\n this.cache = new TtlCache<SearchResult>(this.opts.cacheTtlMs, this.opts.cacheMaxEntries)\n }\n\n /**\n * Validate + normalize raw params into a `SearchInput` or throw `SearchError`.\n * Pure function: no side effects, no rate-limit consumption.\n */\n prepare(raw: RawSearchParams): SearchInput {\n const query = (raw.query ?? '').trim()\n if (query.length < this.opts.minQueryLength) {\n throw new SearchError(\n `Query must be at least ${this.opts.minQueryLength} characters`,\n 400,\n SearchErrorCodes.QueryTooShort,\n )\n }\n\n const mode = this.resolveMode(raw.mode)\n const limit = this.resolveLimit(raw.limit)\n const offset = this.resolveOffset(raw.offset)\n const filters = raw.filters ?? {}\n\n return {\n query,\n mode,\n limit,\n offset,\n filters,\n user: raw.user,\n ...(raw.locale !== undefined && { locale: raw.locale }),\n }\n }\n\n /**\n * Enforce per-user + per-IP rate limits. Throws `SearchError(429)` on exceed.\n *\n * Both limits must pass — keeps a single user from masking IP-level abuse,\n * and an aggregate IP cap from being saturated by one chatty user.\n */\n enforceRateLimit(userId: string, ip: string, now: number = Date.now()): void {\n if (!this.perUserLimiter.consume(`u:${userId}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n if (!this.perIpLimiter.consume(`ip:${ip}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n }\n\n /**\n * Run a provider through cache + dedupe + timeout. Caller is expected to\n * have already called `prepare()` and `enforceRateLimit()` (and any\n * permission gating) before reaching this.\n */\n async execute(provider: SearchProvider, input: SearchInput): Promise<SearchResult> {\n const key = fingerprint(provider.resource, input)\n\n const cached = this.cache.get(key)\n if (cached) return cached\n\n const dedupeActive = this.opts.dedupe && this.inflight.size < SearchMechanics.INFLIGHT_MAX\n if (this.opts.dedupe) {\n const inflight = this.inflight.get(key)\n if (inflight) return inflight\n }\n\n const promise = this.runWithTimeout(provider, input)\n .then((result) => {\n this.cache.set(key, result)\n return result\n })\n .finally(() => {\n if (dedupeActive) this.inflight.delete(key)\n })\n\n if (dedupeActive) this.inflight.set(key, promise)\n return promise\n }\n\n private async runWithTimeout(\n provider: SearchProvider,\n input: SearchInput,\n ): Promise<SearchResult> {\n const controller = new AbortController()\n const timeoutMs = this.opts.timeoutMs\n\n // Wall-clock guarantee — a misbehaving provider that ignores the abort\n // signal can't keep the request hanging. The `signal` is still passed in\n // so well-behaved providers cancel underlying work; the race ensures the\n // route returns to the caller within `timeoutMs` regardless.\n let timer: ReturnType<typeof setTimeout> | undefined\n const timeoutPromise = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n controller.abort()\n reject(\n new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n ),\n )\n }, timeoutMs)\n })\n\n try {\n return await Promise.race([provider.search(input, controller.signal), timeoutPromise])\n } catch (err) {\n // Provider rejected. If the timer already fired, fold the rejection into\n // a typed timeout error so the route maps cleanly to 504. Preserve the\n // original error as `cause` so the outer admin handler's logger surfaces\n // it during incident review (otherwise a real provider failure that\n // happened to arrive after abort would be silently re-labelled).\n if (controller.signal.aborted && !(err instanceof SearchError)) {\n throw new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n { cause: err },\n )\n }\n throw err\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n }\n\n private resolveMode(raw: string | null | undefined): SearchMode {\n if (raw === undefined || raw === null || raw === '') return 'phrase'\n if (!VALID_MODES.has(raw as SearchMode)) {\n throw new SearchError(`Unknown query mode '${raw}'`, 400, SearchErrorCodes.UnsupportedMode)\n }\n return raw as SearchMode\n }\n\n private resolveLimit(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return this.opts.defaultLimit\n const n = parseStrictInt(raw)\n if (n === null || n <= 0) {\n throw new SearchError('limit must be a positive integer', 400, SearchErrorCodes.InvalidPagination)\n }\n return Math.min(n, this.opts.maxLimit)\n }\n\n private resolveOffset(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return 0\n const n = parseStrictInt(raw)\n if (n === null || n < 0) {\n throw new SearchError('offset must be a non-negative integer', 400, SearchErrorCodes.InvalidPagination)\n }\n if (n > this.opts.maxOffset) {\n throw new SearchError(\n `offset must be <= ${this.opts.maxOffset}`,\n 400,\n SearchErrorCodes.InvalidPagination,\n )\n }\n return n\n }\n}\n\n/**\n * Strict integer parser — rejects `'1.5'`, `'1abc'`, `' '`. Returns `null`\n * when the input isn't a whole integer. Numbers go through the same shape\n * check via `Number.isInteger`.\n */\nfunction parseStrictInt(raw: string | number): number | null {\n if (typeof raw === 'number') {\n return Number.isFinite(raw) && Number.isInteger(raw) ? raw : null\n }\n if (!/^-?\\d+$/.test(raw)) return null\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : null\n}\n\n/**\n * Stable fingerprint for cache + dedupe keying. Includes user id since\n * provider results are role/scope-sensitive — two users firing the same\n * query must not see each other's filtered output.\n */\nfunction fingerprint(resource: string, input: SearchInput): string {\n const filters = Object.keys(input.filters)\n .sort()\n .map((k) => `${k}=${[...(input.filters[k] ?? [])].sort().join(',')}`)\n .join('|')\n return [\n resource,\n input.user.id,\n input.locale ?? '',\n input.mode,\n input.limit,\n input.offset,\n input.query,\n filters,\n ].join('\u0000')\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/search",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./admin": {
|
|
12
|
+
"types": "./dist/admin.d.mts",
|
|
13
|
+
"import": "./dist/admin.mjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@murumets-ee/core": "0.12.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.19.39",
|
|
24
|
+
"tsdown": "^0.21.10",
|
|
25
|
+
"typescript": "^5.7.3",
|
|
26
|
+
"vitest": "^2.1.8"
|
|
27
|
+
},
|
|
28
|
+
"typeCoverage": {
|
|
29
|
+
"atLeast": 100
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"dev": "tsdown --watch",
|
|
34
|
+
"test": "vitest"
|
|
35
|
+
}
|
|
36
|
+
}
|